feat: run and test scripts from the AI chat through an argument form (#11001)

* fix: disable a dynamic input when its schema field is disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: extract the run form's argument hygiene into job_args

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give Tabs an opt-in sliding selection indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: share the chat's scroll-fade measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give the chat a run-form contract and incremental job output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: run and test a script from the chat through an argument form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: carry a chat run's card and job across saves and reloads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: render a chat run as a tool call row with its form, logs and result

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: open a pending run form in the sessions preview pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* test: benchmark running a deployed script from the chat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: offer a test run's dynamic options from the draft it previews

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say that a test run's dynselect helper executes on form display

* fix: send a schema default the model omitted when yolo skips the form

* fix: infer a test run's schema when the stored one declares no properties

* docs: tighten the note on the form's mount-time helper job

* fix: apply a nested schema default the bypass posture counts as answered

* fix: apply a declared default to a null value and an optional nested field

* fix: check required fields inside a supplied optional object before bypassing

* fix: read required args as own properties before bypassing the form

* fix: stop the turn from the run form's action row in the preview panel

* refactor: drop the run-form prediction and share its secret minting

* refactor: prefill a proposed secret instead of emptying the field

* docs: correct the comments the run-form prediction left behind

* fix: keep a proposed secret out of the chat's stored messages

* docs: say what a literal secret argument now does

* test: restore the copilotInfo export the aiStore mock omits

* docs: cut the run form's helper-script note to its constraints

* refactor: settle a run form from one entry and fetch a job's logs once

* fix: separate colliding secret paths, gate plan mode, keep polled logs

* fix: mint before the form opens, skip empty fields, show what ran

* revert: mint a run form's secrets at submit, not before it opens

* fix: settle a cancelled run card on the form's arguments, not the proposal

* fix: settle a stopped run form like a cancelled one, and keep an empty secret empty

* fix: snapshot a run's arguments before minting its secrets

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-09 12:31:58 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 22c1a106cf
commit a6abf2c8a7
41 changed files with 4234 additions and 222 deletions
@@ -132,6 +132,10 @@ export async function runEval<THelpers, TOutput>(
setToolStatus: () => {},
removeToolStatus: () => {},
isPlanModeActive,
// Accepts the run form exactly as the model prefilled it: there is nobody here to
// edit the arguments, so a case can assert what the model proposed but never how
// it reacts to the user changing something.
requestRunArgs: async (_toolId, form) => form.args,
onNewToken: (token: string) => {
if (shouldEmitMessageStart) {
onAssistantMessageStart?.();
@@ -86,6 +86,7 @@ vi.mock('$lib/gen', async () => {
previewBenchmarkSchedule,
runBenchmarkDatatableSql,
runBenchmarkFlowByPath,
runBenchmarkScriptByPath,
runBenchmarkScriptPreview,
updateBenchmarkDraft,
listBenchmarkMcpTools
@@ -279,6 +280,18 @@ vi.mock('$lib/gen', async () => {
}
return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody })
},
runScriptByPath: async (data: {
workspace: string
path: string
requestBody?: Record<string, unknown>
}) =>
hasBenchmarkWorkspace(data.workspace)
? runBenchmarkScriptByPath({
workspace: data.workspace,
path: data.path,
args: data.requestBody
})
: actual.JobService.runScriptByPath(data),
runFlowByPath: async (data: {
workspace: string
path: string
+72
View File
@@ -1974,6 +1974,78 @@
judgeChecklist:
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
- id: global-test33-run-deployed-script-with-form
prompt: |-
Run the deployed script `f/evals/global/format_greeting` for me with the name "ada".
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 8
# A session chat is where the run card has a preview pane beside it; run_script
# itself is offered in every chat.
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- run_script
# A draft may declare different arguments than the deployed version being run, so
# the names to prefill have to come from the deployed schema.
- read_workspace_item
forbiddenToolsUsed:
- test_run_script
- call_api_endpoint
- write_script
- deploy_workspace_item
# An empty form pushes the work back onto the user, so the prefill is part of
# what the tool is for.
toolCallArgs:
- tool: run_script
field: args.name
stringIncludesAnyOf:
- ada
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
skipJudge: true
judgeChecklist:
- runs the deployed script through run_script rather than a preview test run or a raw API endpoint
- passes the name "ada" so the confirmation form comes up prefilled
- id: global-test34-run-with-secret-from-variable
prompt: |-
Run the deployed `f/evals/global/billing_sync` for the account `acme` — use the billing
API token we already keep in the workspace.
initial: ai_evals/fixtures/frontend/global/initial/billing_sync_with_secret_arg.json
runtime:
maxTurns: 10
# A session chat is where the run card has a preview pane beside it; run_script
# itself is offered in every chat.
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- run_script
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
# A secret argument is filled by naming the variable that holds it: the value stays in
# the variable and only its path travels. A literal reaches the job as a reference too,
# minted on the way in, but it stays in the tool call the model emitted.
toolCallArgs:
- tool: run_script
field: args.api_token
stringIncludesAnyOf:
- "$var:f/evals/global/stripe_api_token"
- tool: run_script
field: args.account
stringIncludesAnyOf:
- acme
# Running produces no draft, and the judge cannot observe runs; validate via tool use.
skipJudge: true
judgeChecklist:
- fills the secret argument with a reference to the existing workspace variable rather than a literal token
- passes the account "acme"
- does not invent or guess the token's value
- id: global-undo-created-draft
prompt: |-
Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with.
@@ -0,0 +1,37 @@
{
"workspace": {
"variables": [
{
"path": "f/evals/global/stripe_api_token",
"value": "sk_live_do_not_leak_me",
"is_secret": true,
"description": "Token used by the billing sync job",
"labels": ["billing"]
}
],
"scripts": [
{
"path": "f/evals/global/billing_sync",
"summary": "Sync billing records",
"description": "Syncs billing records for one account, authenticating with an API token.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"account": {
"type": "string"
},
"api_token": {
"type": "string",
"password": true,
"description": "API token to authenticate with"
}
},
"required": ["account", "api_token"]
},
"content": "export async function main(account: string, api_token: string) {\n return `synced ${account}`\n}\n"
}
]
}
}
@@ -1078,6 +1078,7 @@
{otherArgs}
{helperScript}
{workspace}
{disabled}
bind:value
format={format ?? ''}
/>
@@ -35,6 +35,9 @@
name: string
/** Workspace the helper script runs in; defaults to the nav workspace. */
workspace?: string
/** Reaches the fallback editor too, which is what renders when there is no
* `helperScript` — a caller disabling this argument means all of it. */
disabled?: boolean
}
let {
@@ -42,7 +45,8 @@
helperScript,
format,
otherArgs: otherArgs,
workspace = undefined
workspace = undefined,
disabled = false
}: Props = $props()
let [inputType, entrypoint] = $derived(format.includes('-') ? format.split('-', 2) : [format, ''])
@@ -190,7 +194,7 @@
items={safeSelectItems(items || [])}
placeholder="Select items"
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
disabled={_items.status === 'loading'}
disabled={disabled || _items.status === 'loading'}
/>
{:else if inputType === 'dynselect'}
<Select
@@ -199,6 +203,7 @@
{items}
bind:filterText
loading={!open && _items.status === 'loading'}
{disabled}
clearable
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
/>
@@ -222,7 +227,7 @@
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default code={JSON.stringify(value, null, 2)} bind:value />
<Module.default code={JSON.stringify(value, null, 2)} {disabled} bind:value />
{/await}
</div>
{/if}
+24 -13
View File
@@ -21,9 +21,18 @@
args: any
argLabel?: string | undefined
workspace?: string | undefined
/** Drop the header's expand-into-a-drawer button, for a caller that already offers a
* way to open the run in full. */
disableExpand?: boolean
}
let { id = undefined, args, argLabel = undefined, workspace = undefined }: Props = $props()
let {
id = undefined,
args,
argLabel = undefined,
workspace = undefined,
disableExpand = false
}: Props = $props()
// Internal flag injected by "test this step" runs to suppress the asset
// dispatcher. Not a real input: shown as a badge instead of a table row,
@@ -125,18 +134,20 @@ ${Object.entries(displayArgs)
<Cell head last>Value</Cell>
</tr>
{#snippet headerAction()}
<div class="center-center -m-1">
<Button
unifiedSize="md"
variant="subtle"
onClick={() => {
jsonStr = JSON.stringify(args, null, 4)
jsonViewer?.openDrawer()
}}
iconOnly
startIcon={{ icon: Expand }}
></Button>
</div>
{#if !disableExpand}
<div class="center-center -m-1">
<Button
unifiedSize="md"
variant="subtle"
onClick={() => {
jsonStr = JSON.stringify(args, null, 4)
jsonViewer?.openDrawer()
}}
iconOnly
startIcon={{ icon: Expand }}
></Button>
</div>
{/if}
{/snippet}
</Head>
@@ -1,7 +1,7 @@
<script lang="ts">
import { VariableService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { generateRandomString } from '$lib/utils'
import { ephemeralSecretPrefix, mintEphemeralSecret } from './secretArgUtils'
import { sendUserToast } from '$lib/toast'
import { Button } from './common'
import Password from './Password.svelte'
@@ -36,27 +36,16 @@
let isGenerating = false
let userPrefix = $derived(
'u/' + ($userStore?.username ?? $userStore?.email)?.split('@')[0] + '/secret_arg/'
)
let username = $derived(($userStore?.username ?? $userStore?.email)?.split('@')[0] ?? '')
let userPrefix = $derived(ephemeralSecretPrefix(username))
async function generateValue() {
if (isGenerating || argReplaced) return
isGenerating = true
const mintWs = ws!
const boundBefore = value
try {
let npath = userPrefix + generateRandomString(12)
let npath = await mintEphemeralSecret(mintWs, username, password)
let nvalue = '$var:' + npath
await VariableService.createVariable({
workspace: mintWs,
requestBody: {
value: password,
is_secret: true,
path: npath,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
// The arg can be replaced the same way while the create is in flight. Nothing ever
// referenced the variable just minted, so delete it; it expires on its own if that fails.
if (value !== boundBefore) {
+7 -29
View File
@@ -24,6 +24,7 @@
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
import { processSecretArgs } from './secretArgUtils'
import { enforceDisabledDefaults, resetKeysToast } from './job_args'
import PowerShellCommonParams from './PowerShellCommonParams.svelte'
let reloadArgs = $state(0)
@@ -60,11 +61,12 @@
export async function run(overrideScheduledForStr?: string | undefined | null) {
let processedArgs: Record<string, any>
const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema)
if (resetKeys.length > 0) {
sendUserToast(resetKeysToast(resetKeys))
}
try {
processedArgs = await processSecretArgs(
enforceDisabledDefaults(args ?? {}, true),
runnable?.schema
)
processedArgs = await processSecretArgs(withDefaults, runnable?.schema)
} catch (e) {
sendUserToast('Failed to process sensitive args: ' + e, true)
return
@@ -178,30 +180,6 @@
}
}
function enforceDisabledDefaults(
args: Record<string, any>,
notify: boolean = false
): Record<string, any> {
const schema = runnable?.schema
if (!schema?.properties) return args
const result = { ...args }
const resetKeys: string[] = []
for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) {
if (prop?.disabled && 'default' in prop) {
if (notify && result[key] !== prop.default) {
resetKeys.push(key)
}
result[key] = prop.default
}
}
if (resetKeys.length > 0) {
sendUserToast(
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
)
}
return result
}
/** Rewrite the open JSON editor from the current args. Only for args replaced from outside
* the editor: entering the JSON view already starts from whatever `args` holds. */
export function syncJsonEditor() {
@@ -322,7 +300,7 @@
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
args = enforceDisabledDefaults(e.detail)
args = enforceDisabledDefaults(e.detail, runnable?.schema).args
}
}}
initialCode={argsToJsonPayload(runnable.schema, args)}
@@ -99,6 +99,7 @@
}}
{disabled}
{id}
data-tab-selected={isSelected ? 'true' : undefined}
>
<div
class={twMerge(
@@ -25,6 +25,15 @@
* Use this when you want to prevent navigation before checking for unsaved changes.
*/
deferSelectedUpdate?: boolean
/**
* Draw the selection as one bar that slides between tabs, rather than a border each tab
* turns on. Opt-in, so every existing strip keeps the border it has: a strip whose tabs
* appear as their content does needs the move to be visible, and a border cannot travel.
* Tabs keep their own bottom border unless the caller turns it off.
*/
slidingIndicator?: boolean
/** Colour of that bar. */
indicatorClass?: string
}
let {
@@ -37,7 +46,9 @@
values = undefined,
children,
content,
deferSelectedUpdate = false
deferSelectedUpdate = false,
slidingIndicator = false,
indicatorClass = 'bg-border-normal'
}: Props = $props()
// Single source of truth for tab state
@@ -61,6 +72,53 @@
selectedStore.set(selected)
})
// Measured off the selected Tab rather than tracked in state: a Tab decides on its own
// whether it is selected (prefix and otherValues matching), and its width is whatever its
// label renders to. Zero width means nothing is selected yet, and the bar stays hidden.
let row: HTMLDivElement | undefined = $state()
let bar = $state({ x: 0, w: 0 })
// Where the bar rests while nothing is selected, so it fades out in place rather than
// sliding to the left edge. A plain local, not state: measureBar runs inside the effect
// below, and reading there the state it writes would make that effect its own dependency.
let lastX = 0
function measureBar() {
const el = row?.querySelector<HTMLElement>('[data-tab-selected="true"]')
if (el) lastX = el.offsetLeft
bar = { x: lastX, w: el ? el.offsetWidth : 0 }
}
// Placing the bar for the first paint. Every later move comes from the observers below: a
// Tab marks itself selected in its own update, which has not run when an effect here does,
// so measuring from this side alone lands the bar on the tab that was selected before.
$effect(() => {
if (!slidingIndicator) return
void row
measureBar()
})
$effect(() => {
if (!slidingIndicator || !row) return
const ro = new ResizeObserver(measureBar)
ro.observe(row)
// The mark moving is the selection changing, and a tab added or removed changes what the
// bar has to sit on. Text counts too: a label rewritten in place — a count arriving,
// Result becoming Error — resizes the tab under the bar without touching the tree, and
// Svelte writes it straight to the node, so childList never sees it.
const mo = new MutationObserver(measureBar)
mo.observe(row, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['data-tab-selected']
})
return () => {
ro.disconnect()
mo.disconnect()
}
})
let hashValues = $derived(values ? values.map((x) => '#' + x) : undefined)
function hashChange() {
@@ -77,8 +135,26 @@
<svelte:window onhashchange={hashChange} />
{#if !hideTabs}
<ScrollableX class={wrapperClass}>
<div class={twMerge('border-b flex flex-row whitespace-nowrap', c)} {style}>
<div
bind:this={row}
class={twMerge(
'border-b flex flex-row whitespace-nowrap',
slidingIndicator ? 'relative' : '',
c
)}
{style}
>
{@render children?.({ selected })}
{#if slidingIndicator}
<span
aria-hidden="true"
class={twMerge(
'pointer-events-none absolute -bottom-px h-0.5 rounded-t-sm transition-[transform,width,opacity] duration-200 ease-out motion-reduce:transition-none',
indicatorClass
)}
style={`left:0; width:${bar.w}px; transform:translateX(${bar.x}px); opacity:${bar.w ? 1 : 0}`}
></span>
{/if}
</div>
</ScrollableX>
{/if}
@@ -227,7 +227,15 @@
const active = document.activeElement
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
if (!focusOnChat) return
// An Escape while a run form is open must not discard what the user typed, so the action
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
// row must not answer for the other.
if (aiChatManager.hasPendingRunForm) {
const row = active?.closest('[data-run-form-actions]')
const toolCallId = row?.getAttribute('data-run-form-actions')
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
} else if (!focusOnChat) return
e.preventDefault()
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
@@ -559,7 +567,7 @@
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
.filter((tool) => tool.requiresConfirmation === true)
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
.map((tool) => ({
name: tool.def.function.name,
// confirmationMessage may be a function of the call args, which we don't
@@ -23,11 +23,14 @@ import {
type ToolCallbacks,
type ToolDisplayMessage,
type UserQuestionDisplay,
type RunFormDisplay,
type RunFormDraft,
type ChatJob,
type ChatJobInit,
type ChatJobStatus,
completedJobToolStatus,
backgroundJobCompletionNote,
createJobUpdateReader,
deriveChatJobStatus,
pendingToolImagesMessage,
trimJob
@@ -57,6 +60,7 @@ import {
buildSummaryMessageContent
} from './compactionPrompt'
import { dfs } from '$lib/components/flows/previousResults'
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
import { createLongHash } from '$lib/editorLangUtils'
import type { AIProvider, UserDraftItemKind } from '$lib/gen'
@@ -189,6 +193,22 @@ const MAX_CONSECUTIVE_COMPACTION_FAILURES = 3
// (panel teardown, save-and-clear) pass their own reason, so the queued-message
// flush can tell "the user wants to move on" from "the turn was torn down".
const USER_CANCEL_REASON = 'user_cancelled'
// Applied wherever a run form stops rendering. Only the form reads the deployed schema,
// so past that point it is a copy of the script's declarations — password and file
// defaults with them — persisted for the life of the chat.
const settledRunForm = (runForm: RunFormDisplay): RunFormDisplay =>
runForm.submitted || runForm.canceled
? { ...runForm, schema: undefined, code: undefined, lang: undefined }
: runForm
/** A run form the chat is holding open, keyed by tool call id. */
type PendingRunForm = {
/** Absent once the loop is no longer waiting: a card restored from history still mounts
* its form and still holds edits, but nothing is left to receive them. */
resolve?: (args: Record<string, any> | undefined) => void
draft: RunFormDraft
submitting: boolean
}
// Built-in `/compact` session command — summarizes the conversation locally
// instead of sending a turn to the model. Matched on the whole input so a
// regular message that merely mentions "/compact" mid-sentence is unaffected.
@@ -495,10 +515,32 @@ export class AIChatManager {
// Consecutive getJob failures per background job, so a vanished/404 job can be
// drained instead of polled forever. Ephemeral, keyed by jobId.
#jobPollFailures = new Map<string, number>()
// Incremental log/result-stream readers, keyed by jobId. A job that detaches out of
// the inline wait keeps streaming into its card through these; each holds its own
// offsets, so one created after a reload refetches from the start.
#jobUpdateReaders = new Map<string, ReturnType<typeof createJobUpdateReader>>()
/** Opens a run in the sessions preview pane. Set by the session runtime;
* undefined in the global side-panel chat, where the tray falls back to opening
* the run in a new browser tab. */
openRunInPreview?: (a: { jobId: string; workspace: string; label: string }) => void
/** Opens a pending run form in the sessions preview pane, on the same tool call the
* chat card holds. Unset outside a session: a chat-bound form has nowhere else to go,
* so the card hides the control rather than offering a tab that cannot run. */
openRunForm?: (a: { toolCallId: string; label: string }) => void
closeRunForm?: (toolCallId: string) => void
/** Hands that tab from the form to the run it just started, in place: the tab keeps its
* position in the strip and stays active if it was. */
showRunInPlaceOfForm?: (a: {
toolCallId: string
jobId: string
workspace: string
label: string
}) => void
/** Whether the panel holds this call's pending form. Answered off the session's tab list,
* so it stays true while the user is on another tab, and per call rather than "the open
* one". Read from a `$derived` the reader subscribes to the tab list through the call.
* The card hides its form on it, which is what keeps exactly one mounted per call. */
isRunFormInPreview?: (toolCallId: string) => boolean
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
closeArtifact?: (artifactId: string) => void
#loading = $state<boolean>(false)
@@ -682,6 +724,15 @@ export class AIChatManager {
{ resolve: (value: boolean) => void; toolName?: string }
>()
private userQuestionCallbacks = new Map<string, (choices: string[] | undefined) => void>()
/**
* One run form's whole life while it waits, so ending it is one delete and cannot end half
* of it. Held here rather than in the form, which unmounts and remounts as it moves between
* the chat card and the preview pane: both are views of one entry.
*
* Entries are replaced rather than mutated, so a `$derived` reading `submitting` fires;
* `draft` keeps its identity across a replacement, which is what the form is bound to.
*/
#runForms = new SvelteMap<string, PendingRunForm>()
private appDatatablesRefreshTimeout: ReturnType<typeof setTimeout> | undefined = undefined
disabledModes: Partial<Record<AIMode, boolean>> = $state({})
@@ -806,19 +857,23 @@ export class AIChatManager {
// turn-end save.
#maskPersistQueue: Promise<void> = Promise.resolve()
#persistModifiedItems(): Promise<void> {
this.#maskPersistQueue = this.#maskPersistQueue.then(() =>
this.historyManager
.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined
)
// Swallow (and log) a failed write so it can't wedge the queue as a
// rejected link — the next persist snapshots the full current set, so
// a lost write self-heals on the next mutation or turn-end save.
.catch((e) => console.error('Failed to persist modified-items mask', e))
)
this.#maskPersistQueue = this.#maskPersistQueue.then(() => {
const { display, jobs } = this.#interruptedSnapshot()
return (
this.historyManager
.saveChat(
display,
this.messages,
this.contextUsage,
this.modifiedItems ? [...this.modifiedItems] : undefined,
jobs
)
// Swallow (and log) a failed write so it can't wedge the queue as a
// rejected link — the next persist snapshots the full current set, so
// a lost write self-heals on the next mutation or turn-end save.
.catch((e) => console.error('Failed to persist modified-items mask', e))
)
})
return this.#maskPersistQueue
}
@@ -849,6 +904,16 @@ export class AIChatManager {
...this.backgroundJobs,
{ ...init, createdAt: Date.now(), status: 'queued', detached: false, reported: false }
]
// The panel was holding this call's form and the call now has a job: the tab follows
// the call rather than being left on a form that has already run.
if (this.isRunFormInPreview?.(init.toolCallId)) {
this.showRunInPlaceOfForm?.({
toolCallId: init.toolCallId,
jobId: init.jobId,
workspace: init.workspace,
label: init.label
})
}
}
/** Merge a partial update into a tracked job by id. */
@@ -962,10 +1027,34 @@ export class AIChatManager {
let anyTerminal = false
for (const job of pending) {
try {
// Its own output first, so a run that detached out of the inline wait keeps
// filling its card. `getJob` alone would freeze a streamed result until the
// job landed — the partial is only on the updates endpoint.
let reader = this.#jobUpdateReaders.get(job.jobId)
if (!reader) {
reader = createJobUpdateReader(job.jobId, job.workspace)
this.#jobUpdateReaders.set(job.jobId, reader)
}
const update = await reader.poll()
if (gen !== this.#jobPollGeneration) return
// Only what this reader has collected: the patch is spread over the card, so
// naming a field it has nothing for erases output already on it.
if (update?.logs || update?.resultStream) {
this.applyToolStatus(job.toolCallId, {
...(update.logs ? { logs: update.logs } : {}),
...(update.resultStream ? { resultStream: update.resultStream } : {})
})
}
// Only when the reader has not already carried them, or when the run may be
// over — the tail written between the last poll and the end is on the job
// alone. Otherwise these are logs the tray strips and the card already has,
// fetched a second time every tick, for every detached job in the chat.
const wantLogs = !update || update.completed
const fetched = await JobService.getJob({
workspace: job.workspace,
id: job.jobId,
noLogs: false,
noLogs: !wantLogs,
noCode: true
})
// The user switched conversations while this getJob was in flight; its
@@ -975,7 +1064,19 @@ export class AIChatManager {
this.#jobPollFailures.delete(job.jobId)
if (fetched.type === 'CompletedJob') {
anyTerminal = true
this.#onBackgroundJobComplete(job, fetched as CompletedJob)
this.#jobUpdateReaders.delete(job.jobId)
// The updates can call a landed job unfinished, and the model reads these
// logs, so a completion seen without them is fetched again.
const completed = wantLogs
? (fetched as CompletedJob)
: ((await JobService.getJob({
workspace: job.workspace,
id: job.jobId,
noLogs: false,
noCode: true
})) as CompletedJob)
if (gen !== this.#jobPollGeneration) return
this.#onBackgroundJobComplete(job, completed)
} else {
// Store the derived status and the trimmed Job together so the tray
// badge (JobStatusIcon) and the scalar status can never drift.
@@ -996,6 +1097,7 @@ export class AIChatManager {
this.#jobPollFailures.set(job.jobId, failures)
if (httpStatus === 404 || failures >= 5) {
this.#jobPollFailures.delete(job.jobId)
this.#jobUpdateReaders.delete(job.jobId)
// Vanished (404) or unreachable after repeated polls. Mark it failed WITH
// a snapshot + tool-card patch (mirroring #onBackgroundJobComplete) so
// neither the tray badge nor the launching tool card stays frozen on
@@ -1013,7 +1115,8 @@ export class AIChatManager {
this.updateJob(job.jobId, { status: 'failure', reported: true, job: trimJob(gone) })
this.applyToolStatus(job.toolCallId, {
content: 'Background job could not be retrieved (it may have been removed)',
error: `Job ${job.jobId} was unreachable`
error: `Job ${job.jobId} was unreachable`,
isLoading: false
})
anyTerminal = true
} else {
@@ -1055,8 +1158,14 @@ export class AIChatManager {
status === 'canceled' || !job.resultFormat
? undefined
: formatChatJobCompletion(completed, job.resultFormat)
// Fill the tool card that launched it (we run outside a turn here).
this.applyToolStatus(job.toolCallId, formatted?.card ?? completedJobToolStatus(completed))
// Fill the tool card that launched it (we run outside a turn here). isLoading is
// normally already false — processToolCall clears it when the launching tool
// returns — but a card restored from a mid-turn checkpoint never saw that return,
// so only this patch can stop it spinning.
this.applyToolStatus(job.toolCallId, {
...(formatted?.card ?? completedJobToolStatus(completed)),
isLoading: false
})
// A user-canceled job needs no model note or auto-resume: the user stopped it
// deliberately, so announcing it (as "FAILED", since a canceled job isn't a
// success) or burning a turn on it would be noise.
@@ -1130,17 +1239,12 @@ export class AIChatManager {
// (saveChat keeps the prior mask when it is undefined).
#jobPersistQueue: Promise<void> = Promise.resolve()
#persistBackgroundJobs(): Promise<void> {
this.#jobPersistQueue = this.#jobPersistQueue.then(() =>
this.historyManager
.saveChat(
this.displayMessages,
this.messages,
this.contextUsage,
undefined,
$state.snapshot(this.backgroundJobs)
)
this.#jobPersistQueue = this.#jobPersistQueue.then(() => {
const { display, jobs } = this.#interruptedSnapshot()
return this.historyManager
.saveChat(display, this.messages, this.contextUsage, undefined, jobs)
.catch((e) => console.error('Failed to persist background jobs', e))
)
})
return this.#jobPersistQueue
}
@@ -1152,6 +1256,7 @@ export class AIChatManager {
this.#jobPollGeneration++
clearTimeout(this.#autoResumeRetry)
this.#autoResumeRetry = undefined
this.#jobUpdateReaders.clear()
this.backgroundJobs = []
this.pendingJobNotes = []
}
@@ -1787,6 +1892,166 @@ export class AIChatManager {
return true
}
requestRunArgs = (
toolId: string,
form: RunFormDisplay,
opts?: { autoAccepted?: boolean }
): Promise<Record<string, any> | undefined> => {
// The tool reads the schema before it asks, so a stop during that read drains the
// callbacks and settles the card before this runs. Installing one then would park
// the turn on a form the settled card no longer renders, leaving nothing able to
// resolve it. The controller is per-turn, so a later turn still opens.
if (this.abortController?.signal.aborted) {
// Settle the form the tool attached after the stop. Its card is about to stop
// loading without ever having rendered, and settledToolDisplay only reaches a
// loading one — so this is the last point the schema, with the script's own
// password and file defaults, can be dropped. No card copy: the stop path writes
// what the row says.
this.#settleRunForm(toolId, undefined)
return Promise.resolve(undefined)
}
// Ahead of the wait, not of the stop above: the caller settled this form before
// attaching it, so its card renders no fields and nothing here could ever resolve.
if (opts?.autoAccepted) {
return Promise.resolve(form.args)
}
// Seeded from the caller's copy, before the card renders: the file arguments on
// `displayMessages` are redacted, so a draft built from those would open the form on
// the marker rather than on the bytes the model proposed.
const entry = this.#runFormEntry(toolId, form)
return new Promise((resolve) => {
this.#runForms.set(toolId, { ...entry, resolve })
})
}
/**
* The entry a run form edits through, created on first mount and shared by every later one.
*
* Deep snapshots, never the message's own values: those are `$state` proxies off
* `displayMessages`, and SchemaForm edits args and schema in place (it reorders the
* schema on mount), so anything shallower writes each keystroke a nested password
* included into the persisted transcript.
*/
#runFormEntry = (toolId: string, runForm: RunFormDisplay): PendingRunForm => {
const existing = this.#runForms.get(toolId)
if (existing) return existing
const draft = $state({
args: ($state.snapshot(runForm.args) ?? {}) as Record<string, any>,
schema: ($state.snapshot(runForm.schema) ?? {}) as Record<string, any>
})
const entry: PendingRunForm = { draft, submitting: false }
this.#runForms.set(toolId, entry)
return entry
}
runFormDraft = (toolId: string, runForm: RunFormDisplay): RunFormDraft =>
this.#runFormEntry(toolId, runForm).draft
markRunFormStarted = (toolId: string) => this.#patchRunForm(toolId, { started: true })
// A form restored from history has an entry once it mounts, but no resolve: the loop
// that opened it is gone.
isRunFormPending = (toolId: string): boolean => !!this.#runForms.get(toolId)?.resolve
isRunFormSubmitting = (toolId: string): boolean => this.#runForms.get(toolId)?.submitting ?? false
/** False when a submit is already in flight for this call, so the caller can drop a
* second one rather than mint a second set of ephemeral secret variables for it. */
beginRunFormSubmit = (toolId: string): boolean => {
const entry = this.#runForms.get(toolId)
if (!entry || entry.submitting) return false
this.#runForms.set(toolId, { ...entry, submitting: true })
return true
}
endRunFormSubmit = (toolId: string) => {
const entry = this.#runForms.get(toolId)
if (entry?.submitting) this.#runForms.set(toolId, { ...entry, submitting: false })
}
/** Whether any form of this chat is still waiting on the user. Asked instead of looking
* the form up in the panel's DOM: when the preview holds it, the card is collapsed and
* the only mounted copy is outside the panel where a DOM query would miss it and let
* Escape discard what has been typed. */
get hasPendingRunForm(): boolean {
for (const entry of this.#runForms.values()) if (entry.resolve) return true
return false
}
/** False when the form is no longer pending, so the caller can say so instead of
* leaving its submit button spinning on a run that will never start. */
handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => {
if (!this.isRunFormPending(toolId)) return false
this.#settleRunForm(toolId, args)
return true
}
handleRunFormCancel = (toolId: string) => {
// The card's own copy is settled here rather than only in the tool's fn, which a form
// restored from history no longer has: Cancel is that card's one way out, and while it
// stays active the whole session reads as needs-confirmation (getSessionChatStatus asks
// pendingUserAction before loading). Clearing isLoading is part of settling — canceled
// alone unmounts the form but leaves the card shimmering.
this.#settleRunForm(toolId, undefined, (runForm) => ({
isLoading: false,
error: 'Cancelled by user',
content: `Run of "${runForm.path}" cancelled by user`
}))
}
/**
* The one way a run form stops waiting on the user: `submitted` is the arguments to run
* with, `undefined` a cancellation.
*
* `card` is for a settler that also owns what the row reads pressing Cancel does, a
* stopped turn leaves it to settledToolDisplay.
*/
#settleRunForm = (
toolId: string,
submitted: Record<string, any> | undefined,
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
) => {
const entry = this.#runForms.get(toolId)
// Its draft holds whatever was typed into the form, a minted password included.
this.#runForms.delete(toolId)
// Cancelled, so no run follows it into that tab (a submitted one is handed over by
// registerJob instead) — take the tab with it rather than leaving a dead form open.
if (submitted === undefined) this.closeRunForm?.(toolId)
const cancelledArgs =
submitted === undefined && entry ? this.#settledFormArgs(entry) : undefined
this.#patchRunForm(
toolId,
submitted ? { submitted: true } : { canceled: true },
cancelledArgs ? (runForm) => ({ ...card?.(runForm), parameters: cancelledArgs }) : card
)
entry?.resolve?.(submitted)
}
/**
* What a form that never ran leaves on its card. A run writes its own arguments there once
* it has them and a cancellation never reaches that write, so without this the card keeps
* the proposal it was published on naming a secret the field had already replaced with a
* reference. A reference stands, anything still literal does not.
*/
#settledFormArgs = (entry: PendingRunForm): Record<string, any> =>
redactFileArgs(redactSecretArgs(entry.draft.args, entry.draft.schema), entry.draft.schema)
#patchRunForm = (
toolId: string,
patch: Partial<RunFormDisplay>,
card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage>
) => {
this.displayMessages = this.displayMessages.map((message) =>
message.role === 'tool' && message.tool_call_id === toolId && message.runForm
? {
...message,
...card?.(message.runForm),
runForm: settledRunForm({ ...message.runForm, ...patch })
}
: message
)
}
setAiChatInput(aiChatInput: AIChatInput | null) {
this.aiChatInput = aiChatInput
}
@@ -3311,7 +3576,7 @@ export class AIChatManager {
)
if (messages.length === this.messages.length) return
checkpointedShape = shape
const display = this.settledToolDisplay(this.displayMessages, 'Interrupted')
const { display, jobs } = this.#interruptedSnapshot()
// onMessageEnd is what gives streamed text its bubble, and it clears
// currentReply doing so — text still there has none, and without one the
// reply returns as context the reader cannot see.
@@ -3331,7 +3596,8 @@ export class AIChatManager {
// partial turn — enough to skip the compaction its next send needs.
// Omitting drops the field, which is the "readers estimate" fallback.
undefined,
this.modifiedItems ? [...this.modifiedItems] : undefined
this.modifiedItems ? [...this.modifiedItems] : undefined,
jobs
)
} catch (e) {
console.error('Failed to checkpoint chat mid-turn', e)
@@ -3701,6 +3967,8 @@ export class AIChatManager {
isPlanModeActive: () => this.planModeActive,
onToolBlockedByPlanMode: this.planMode.noteBlockedTool,
requestUserQuestion: this.requestUserQuestion,
requestRunArgs: this.requestRunArgs,
markRunFormStarted: this.markRunFormStarted,
onItemModified: (kind, path) => this.recordModifiedItem(kind, path),
onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to),
onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path),
@@ -3957,6 +4225,19 @@ export class AIChatManager {
resolveQuestion(undefined)
}
this.userQuestionCallbacks.clear()
for (const [toolId, entry] of this.#runForms) {
entry.resolve?.(undefined)
// Stopping the turn is the form's other way out, and the draft dies with this loop:
// settledToolDisplay settles the card below without ever seeing what was typed.
this.#patchRunForm(toolId, {}, () => ({ parameters: this.#settledFormArgs(entry) }))
// The form settles with the turn, so a preview tab holding it goes too rather
// than being left on a form that can no longer run.
this.closeRunForm?.(toolId)
}
// Not through #settleRunForm: settledToolDisplay settles every card of the stopped
// turn at once, and it alone can tell a run that reached the server from one that
// never did.
this.#runForms.clear()
const cancelReason = reason ?? USER_CANCEL_REASON
console.log('cancelling request:', {
reason: cancelReason,
@@ -4193,6 +4474,15 @@ export class AIChatManager {
if (this.isJobNonTerminal(j.status)) j.detached = true
}
if (this.backgroundJobs.length > 0) this.backgroundJobs = [...this.backgroundJobs]
// Reloading resolves no card on its own. Settle every one the poller above
// will not reach, whoever wrote it — a record from a build that stored cards
// without their jobs would otherwise restore one that spins forever.
const pollable = this.#pollableToolCalls()
this.displayMessages = this.settledToolDisplay(
this.displayMessages,
'Interrupted',
(message) => !pollable.has(message.tool_call_id)
)
this.#ensureJobPoller()
// Message-attached files live in the transcript, not in the store's
// persistence — rebuild their rows so the loaded chat's references are
@@ -4522,10 +4812,25 @@ export class AIChatManager {
// through here first.
private settledToolDisplay = (
messages: DisplayMessage[],
messageText: string
messageText: string,
shouldSettle: (message: ToolDisplayMessage) => boolean = () => true
): DisplayMessage[] =>
messages.map((message) => {
if (message.role === 'tool' && (message.isLoading || message.isQueued)) {
if (
message.role === 'tool' &&
(message.isLoading || message.isQueued) &&
shouldSettle(message)
) {
// Stopping the turn does not stop the job, and between Run and the job's id
// there is no way to know whether the server queued one: nothing threads the
// abort into that request, so it lands either way. That window says so
// rather than picking a side — "canceled" hides a script that ran, "started"
// invents one that did not.
const runState = message.runForm?.started
? 'started'
: message.runForm?.submitted
? 'starting'
: 'idle'
return {
...message,
isLoading: false,
@@ -4535,14 +4840,25 @@ export class AIChatManager {
// and a card that hides its result as still-streaming.
needsConfirmation: false,
isStreamingArguments: false,
// A question's card disappears once canceled, so keep the question
// itself readable in the collapsed header.
// An interactive card disappears once canceled, so keep what it was
// asking readable in the collapsed header.
content: message.userQuestion
? `Asked: ${message.userQuestion.question}${messageText}`
: messageText,
error: messageText,
: message.runForm
? runState === 'started'
? `Run ${message.runForm.path} — started, stopped tracking before it finished`
: runState === 'starting'
? `Run ${message.runForm.path}${messageText} while starting, check the runs page for a job`
: `Run ${message.runForm.path}${messageText}`
: messageText,
// A run that reached the server keeps whatever the job reported: it is not
// this turn's error, and the jobs tray is still following it.
...(runState === 'idle' ? { error: messageText } : {}),
userQuestion: message.userQuestion
? { ...message.userQuestion, canceled: true }
: undefined,
runForm: message.runForm
? settledRunForm({ ...message.runForm, canceled: runState === 'idle' })
: undefined
}
}
@@ -4552,6 +4868,36 @@ export class AIChatManager {
cancelLoadingTools = (messageText: 'Canceled' | 'Error' = 'Canceled') => {
this.displayMessages = this.settledToolDisplay(this.displayMessages, messageText)
}
/** What the transcript would be if the turn stopped here for the writes that fire
* mid-turn without ending it. Loading is a property of this page: reloading resolves no
* card, so one stored still pending comes back asking for input nothing can deliver.
* Settles the stored copy only; the live turn keeps its cards.
*
* Except a card the poller will resolve after a reload: settling that one stores an
* "Interrupted" error the patch a completed job merges in carries nothing to clear.
* Which cards those are is loadPastChat's question, asked the same way and the poller
* only knows the jobs stored in the same record, so both go into the same saveChat. */
#interruptedSnapshot = (): { display: DisplayMessage[]; jobs: ChatJob[] } => {
const polled = this.#pollableToolCalls()
return {
display: this.settledToolDisplay(
this.displayMessages,
'Interrupted',
(message) => !polled.has(message.tool_call_id)
),
jobs: $state.snapshot(this.backgroundJobs) as ChatJob[]
}
}
/** Tool calls a restored transcript can still resolve. loadPastChat re-attaches the
* poller to every non-terminal job and nothing else runs after a reload, so this is
* the whole set asked identically when storing a card and when restoring one, or
* the two drift and a card is kept by one and stranded by the other. */
#pollableToolCalls = (): Set<string> =>
new Set(
this.backgroundJobs.filter((j) => this.isJobNonTerminal(j.status)).map((j) => j.toolCallId)
)
}
export const aiChatManager = new AIChatManager()
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { writable } from 'svelte/store'
import type { FlowAIChatHelpers } from './flow/core'
import type { PipelineAIChatHelpers } from './pipeline/core'
import type { CurrentEditor } from '$lib/components/flows/types'
@@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => ({
runChatLoop: vi.fn(),
listResource: vi.fn(),
getJob: vi.fn(),
getJobUpdates: vi.fn(),
whoami: vi.fn(),
workspace: 'test_workspace' as string | undefined,
// The workspace being browsed, which a session chat's own workspace need not be.
@@ -60,7 +62,8 @@ vi.mock('$lib/gen', () => ({
whoami: mocks.whoami
},
JobService: {
getJob: mocks.getJob
getJob: mocks.getJob,
getJobUpdates: mocks.getJobUpdates
}
}))
@@ -121,6 +124,9 @@ vi.mock('$lib/toast', () => ({
}))
vi.mock('$lib/aiStore', () => ({
// `sendRequest` reads it before anything else, so a test that goes through a real turn
// rather than driving the manager directly needs it present and enabled.
copilotInfo: writable({ enabled: true, workspaceDisabled: false, aiModels: [] }),
getCurrentModel: mocks.getCurrentModel,
tryGetCurrentModel: mocks.tryGetCurrentModel,
getCombinedCustomPrompt: () => '',
@@ -172,6 +178,10 @@ beforeEach(() => {
mocks.getOpenaiClient.mockReturnValue({})
mocks.getAnthropicClient.mockReturnValue({})
mocks.listResource.mockResolvedValue([])
// Re-seeded here rather than in the factory: clearAllMocks keeps implementations, so a
// test that makes the updates endpoint fail would otherwise leave it failing for the rest
// of the file. Neutral by default — completion is getJob's answer.
mocks.getJobUpdates.mockResolvedValue({ completed: false, running: true })
mocks.workspace = 'test_workspace'
mocks.runChatLoop.mockResolvedValue({
addedMessages: [],
@@ -226,6 +236,241 @@ describe('AIChatManager unmounted-chat guard', () => {
})
})
describe('AIChatManager run form', () => {
// A transcript can be persisted mid-turn (a background job's status write) and
// restored into a fresh manager, which has none of the turn's callbacks. Cancel is
// then the card's only exit, and until it settles pendingUserAction keeps the whole
// session reading as needs-confirmation.
// A save that fires mid-turn (jobs tray, review dock) stores a transcript nothing
// will resume. Storing a card still pending brings back a form whose Run resolves
// no callback.
it('stores loading cards settled when a mid-turn save fires', async () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_r',
content: 'Waiting for you to confirm the arguments of "f/a/b"',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {} }
}
]
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
manager.markJobsReviewed([])
manager.dismissJob('nope')
await Promise.resolve()
const { isActiveRunForm } = await import('./shared')
const stored = saveChat.mock.calls.at(-1)?.[0]?.[0]
expect(stored?.runForm?.canceled).toBe(true)
// What every mid-turn save has to hold: no stored card renders a live form. A
// save path added without settling would restore a Run that resolves nothing.
expect(isActiveRunForm(stored!)).toBe(false)
// The live card is untouched — the turn is still parked on it.
expect(manager.displayMessages[0].isLoading).toBe(true)
})
// Only the rendered form reads the schema, and a settled card renders none. Kept, it
// would sit in history for the life of the chat with the script's own password and
// file defaults inside it.
it('drops the schema from a card that has stopped showing a form', () => {
const manager = new AIChatManager()
const runForm = { path: 'f/a/b', schema: { properties: { tok: { password: true } } }, args: {} }
manager.displayMessages = [
{ role: 'tool', tool_call_id: 'call_r', content: '', isLoading: true, runForm }
]
manager.handleRunFormCancel('call_r')
expect(manager.displayMessages[0].runForm?.schema).toBeUndefined()
expect(manager.displayMessages[0].runForm?.canceled).toBe(true)
})
// A run writes what it ran onto the card; a cancelled one never gets there, so without
// this its Inputs tab still names the proposal the card was published on — a secret the
// mounted field had already replaced with a reference.
it('settles a cancelled card on what the form held, not on the proposal', async () => {
const manager = new AIChatManager()
const schema = {
properties: { token: { password: true }, spare: { password: true }, note: {} }
}
const runForm = {
path: 'f/a/b',
schema,
args: { token: 'hunter2', spare: 'untouched', note: 'hello' }
}
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_r',
content: '',
isLoading: true,
parameters: { ...runForm.args },
runForm
}
]
void manager.requestRunArgs('call_r', runForm)
const draft = manager.runFormDraft('call_r', runForm)
draft.args.token = '$var:u/admin/secret_arg/AbC'
draft.args.note = 'goodbye'
manager.handleRunFormCancel('call_r')
expect(manager.displayMessages[0].parameters).toEqual({
token: '$var:u/admin/secret_arg/AbC',
// Never minted, so still the secret itself.
spare: '<hidden>',
note: 'goodbye'
})
})
// Stopping the turn is the form's other way out, and it settles cards through
// settledToolDisplay rather than through #settleRunForm.
it('settles a stopped form on what it held too', () => {
const manager = new AIChatManager()
const schema = { properties: { token: { password: true }, note: {} } }
const runForm = { path: 'f/a/b', schema, args: { token: 'hunter2', note: 'hello' } }
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_r',
content: '',
isLoading: true,
parameters: { ...runForm.args },
runForm
}
]
void manager.requestRunArgs('call_r', runForm)
const draft = manager.runFormDraft('call_r', runForm)
draft.args.token = '$var:u/admin/secret_arg/AbC'
draft.args.note = 'goodbye'
manager.cancel()
expect(manager.displayMessages[0].parameters).toEqual({
token: '$var:u/admin/secret_arg/AbC',
note: 'goodbye'
})
})
// The tool reads the deployed schema before it asks for arguments. A stop during that
// read drains the callbacks and settles the card, so a waiter installed afterwards was
// one no rendered form could resolve: the turn stayed loading until a second stop.
it('installs no run-form waiter once the turn is stopped', async () => {
const manager = new AIChatManager()
// The turn the tool is running under; cancel aborts it.
manager.abortController = new AbortController()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_late',
content: 'Executing...',
isLoading: true
}
]
manager.cancel()
await expect(
manager.requestRunArgs('call_late', { path: 'f/a/b', schema: {}, args: {} })
).resolves.toBeUndefined()
expect(manager.isRunFormPending('call_late')).toBe(false)
})
// The stop lands while the tool is still reading the deployed schema, so the form is
// attached after the card was settled. Nothing settles it a second time — the card
// stops loading without the form ever rendering — so the schema would otherwise stay
// in the transcript with the script's own password default inside it.
it('drops the schema from a form attached after the turn was stopped', async () => {
const manager = new AIChatManager()
manager.abortController = new AbortController()
manager.displayMessages = [
{ role: 'tool', tool_call_id: 'call_x', content: 'Executing...', isLoading: true }
]
manager.cancel()
const runForm = {
path: 'f/a/b',
schema: { properties: { tok: { password: true, default: 'hunter2' } } },
args: {}
}
manager.applyToolStatus('call_x', { content: 'Waiting for you...', runForm, isLoading: true })
await expect(manager.requestRunArgs('call_x', runForm)).resolves.toBeUndefined()
expect(manager.displayMessages[0].runForm?.schema).toBeUndefined()
expect(JSON.stringify(manager.displayMessages[0])).not.toContain('hunter2')
})
// Stop ends the turn, not the job: the deployed script is already running with all
// its side effects, so the transcript must not record it as cancelled.
it('does not mark a started run cancelled when the turn is stopped', () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_s',
content: 'Running "f/a/b"...',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true, started: true }
}
]
manager.cancelLoadingTools()
const settled = manager.displayMessages[0]
expect(settled.runForm?.canceled).toBe(false)
expect(settled.error).toBe(undefined)
expect(settled.isLoading).toBe(false)
})
// Run flips `submitted` a round trip before the job id arrives, and nothing threads
// the stop into that request — so the card claims neither outcome for that window.
it('claims neither outcome for a run stopped while its job was starting', () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_s',
content: 'Running "f/a/b"...',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true }
}
]
manager.cancelLoadingTools()
const settled = manager.displayMessages[0]
expect(settled.runForm?.canceled).toBe(false)
expect(settled.error).toBe(undefined)
expect(settled.content).toBe(
'Run f/a/b — Canceled while starting, check the runs page for a job'
)
})
// Only a form the user never submitted was cancelled outright.
it('marks an unsubmitted run cancelled when the turn is stopped', () => {
const manager = new AIChatManager()
manager.displayMessages = [
{
role: 'tool',
tool_call_id: 'call_u',
content: 'Waiting for you to confirm the arguments of "f/a/b"',
isLoading: true,
runForm: { path: 'f/a/b', schema: {}, args: {} }
}
]
manager.cancelLoadingTools()
const settled = manager.displayMessages[0]
expect(settled.runForm?.canceled).toBe(true)
expect(settled.content).toBe('Run f/a/b — Canceled')
})
})
describe('AIChatManager.sendOrQueue', () => {
// The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no
// composer to enforce the composer's rule for them: a second loop on one manager
@@ -2147,6 +2392,56 @@ describe('AIChatManager queued messages', () => {
])
})
// A checkpoint that leaves a card loading is betting the poller resolves it after
// the reload, and the poller only knows the jobs stored in the same record —
// registering one does not write it.
it('stores the job behind a card the checkpoint leaves loading', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
mocks.runChatLoop.mockImplementationOnce(async (config: any) => {
config.addedMessages.push({
role: 'assistant' as const,
content: '',
tool_calls: [
{ id: 't1', type: 'function' as const, function: { name: 'run_script', arguments: '{}' } }
]
})
// Inside the inline wait: the job is registered and still running, so no
// persist path has run for it yet.
manager.registerJob({
jobId: 'job-1',
toolCallId: 't1',
kind: 'script',
label: 'f/a/b',
workspace: 'ws'
})
config.callbacks.setToolStatus('t1', { content: 'Running...', isLoading: true })
leavePage.forEach((fn) => fn())
// The wait ends normally, so the only save that stored this card loading is
// the checkpoint that landed inside it.
config.callbacks.setToolStatus('t1', { content: 'Ran', isLoading: false })
manager.updateJob('job-1', { status: 'success' })
config.addedMessages.push({ role: 'tool' as const, tool_call_id: 't1', content: 'ran' })
return {
addedMessages: config.addedMessages,
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
}
})
await manager.sendRequest({ instructions: 'run it' })
const checkpoint = saveChat.mock.calls.find(([display]) =>
(display as DisplayMessage[]).some(
(m) => m.role === 'tool' && m.tool_call_id === 't1' && m.isLoading
)
)
expect(checkpoint).toBeDefined()
expect(checkpoint?.[4]).toEqual([expect.objectContaining({ jobId: 'job-1' })])
})
it('stops checkpointing once the turn commits, so the transcript is never doubled', async () => {
const leavePage = stubHidingPage()
const manager = createManager()
@@ -2648,6 +2943,33 @@ describe('AIChatManager queued messages', () => {
expect(manager.modifiedItems?.size).toBe(0)
})
// Reloading resolves no card on its own. Only the poller can, and only for the jobs
// that came back with the transcript — so a stored card without one must arrive
// settled, whichever build wrote it.
it('settles a restored loading card that no job came back to resolve', async () => {
const manager = createManager(createInputMock())
mocks.getJob.mockResolvedValue({ type: 'QueuedJob', id: 'job-1' })
vi.spyOn(manager.historyManager, 'loadPastChat').mockReturnValue({
id: 'reloaded',
title: 'Reloaded',
displayMessages: [
{ role: 'tool', tool_call_id: 'orphan', content: 'Running...', isLoading: true },
{ role: 'tool', tool_call_id: 'polled', content: 'Running...', isLoading: true }
],
actualMessages: [],
lastModified: 0
} as unknown as ReturnType<typeof manager.historyManager.loadPastChat>)
vi.spyOn(manager.historyManager, 'getBackgroundJobs').mockReturnValue([
{ jobId: 'job-1', toolCallId: 'polled', status: 'running' }
] as any)
await manager.loadPastChat('reloaded')
const card = (id: string) => manager.displayMessages.find((m) => m.tool_call_id === id) as any
expect(card('orphan')).toMatchObject({ isLoading: false, error: 'Interrupted' })
expect(card('polled').isLoading).toBe(true)
})
it('seeds a session chat mask from its stored modified-items', async () => {
const manager = createManager(createInputMock())
manager.isSessionChat = true
@@ -3727,6 +4049,36 @@ describe('AIChatManager background job completion', () => {
resultFormat: { kind: 'datatable' as const, datatableName: 'main' }
}
// Live, processToolCall clears isLoading when the launching tool returns. A card
// restored from a mid-turn checkpoint never sees that return, so completing its job
// is the only thing left that can stop it spinning.
it('stops a restored card spinning when the poller completes its job', async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
manager.displayMessages = [
{ role: 'tool', tool_call_id: 'tc-1', content: 'Running...', isLoading: true } as any
]
mocks.getJob.mockResolvedValue(completed({ result: [{ n: 1 }] }))
await completeDetachedJob(manager)
expect((manager.displayMessages[0] as any).isLoading).toBe(false)
})
// Streaming rides on a second endpoint; landing the job must not. A poll that always
// fails would otherwise spend the failure budget and drain a job that finished, leaving
// the card on "unreachable".
it('completes a job whose updates endpoint keeps failing', async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
mocks.getJobUpdates.mockRejectedValue(new Error('updates unavailable'))
mocks.getJob.mockResolvedValue(completed({ result: [{ n: 1 }] }))
await completeDetachedJob(manager)
expect(manager.backgroundJobs[0]?.status).toBe('success')
})
it('reconstructs the datatable result contract from the persisted resultFormat', async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
@@ -3740,12 +4092,48 @@ describe('AIChatManager background job completion', () => {
// the SQL contract (row count + shaped rows) rather than generic job output.
expect(applyToolStatus).toHaveBeenCalledWith('tc-1', {
content: 'Query returned 2 row(s)',
result: JSON.stringify([{ n: 1 }, { n: 2 }], null, 2)
result: JSON.stringify([{ n: 1 }, { n: 2 }], null, 2),
isLoading: false
})
expect(manager.pendingJobNotes).toHaveLength(1)
expect(manager.pendingJobNotes[0]).toContain('"rowCount": 2')
})
// Detaching persists while the card is still loading. Storing it as interrupted would
// stick, because the patch a completed job merges in carries no error to clear.
it("stores a detached job's card unsettled, so a later success is not left an error", async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
manager.applyToolStatus('tc-1', { content: 'running in background', isLoading: true })
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
manager.markJobDetached('job-1')
await vi.waitFor(() => expect(saveChat).toHaveBeenCalled())
const stored = (saveChat.mock.calls.at(-1)?.[0] as any[]).find((m) => m.tool_call_id === 'tc-1')
expect(stored.error).toBeUndefined()
expect(stored.content).toBe('running in background')
})
// A job still waiting inline is detached by the restore and polled like any other, so
// its card is one the poller resolves too — storing it as interrupted sticks, for the
// same reason an already-detached one would.
it("stores an inline job's card unsettled, so a later success is not left an error", async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
manager.registerJob({ ...datatableJob, jobId: 'job-2', toolCallId: 'tc-2' })
manager.applyToolStatus('tc-1', { content: 'running', isLoading: true })
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
// The other job reaching a terminal status is what fires the save; job-1 is still
// inside its inline wait when it lands.
manager.updateJob('job-2', { status: 'success' })
await vi.waitFor(() => expect(saveChat).toHaveBeenCalled())
const stored = (saveChat.mock.calls.at(-1)?.[0] as any[]).find((m) => m.tool_call_id === 'tc-1')
expect(stored.error).toBeUndefined()
})
it('skips reconstruction and emits no note for a canceled detached job', async () => {
const manager = new AIChatManager()
manager.registerJob(datatableJob)
@@ -3757,9 +4145,13 @@ describe('AIChatManager background job completion', () => {
// A user cancel isn't a result to shape or a completion to announce.
expect(manager.pendingJobNotes).toHaveLength(0)
expect(manager.backgroundJobs[0]?.status).toBe('canceled')
// The raw result, not the shaping this job's resultFormat would have applied — and no
// `error`, which is what keeps the card off the failure styling.
expect(applyToolStatus).toHaveBeenCalledWith('tc-1', {
content: 'Background job canceled',
logs: expect.anything()
result: expect.stringContaining('"n": 1'),
logs: expect.anything(),
isLoading: false
})
})
@@ -245,7 +245,7 @@
viewport bottom) so the mount scrollIntoView leaves the Submit button uncovered. -->
<div
bind:this={cardNode}
class="scroll-mb-8 rounded-md border border-border-light bg-surface p-3"
class="scroll-mb-8 rounded-md border border-border-light bg-surface-tertiary p-3 shadow-sm"
data-chat-keyboard-scope="ask-user-question"
use:focusActiveOnBackgroundClick
>
@@ -6,6 +6,11 @@
interface Props {
label: string
/** Set before the label and left unemphasised, so `labelClass` lifts the subject alone:
* the part of the heading that is grammar stays quiet. A step lighter than the row's own
* label weight, because a caller emphasising the label is also setting a proportional
* typeface, whose medium reads heavier than the mono one at this size. */
labelPrefix?: string
expanded: boolean
onToggle: () => void
// A card with nothing to reveal keeps the header inert (no chevron, no
@@ -13,6 +18,9 @@
toggleable?: boolean
// Sweeps a highlight across the label while the row is in progress.
shimmer?: boolean
// Ahead of the label, inside the toggle button: a status that reads as part of the
// row rather than as another control, leaving the chevron next to the label it opens.
headerLeft?: Snippet
// Pinned to the right of the header row, outside the toggle button.
headerRight?: Snippet
// Always-visible content between the header and the expandable body.
@@ -26,10 +34,12 @@
let {
label,
labelPrefix,
expanded,
onToggle,
toggleable = true,
shimmer = false,
headerLeft,
headerRight,
belowHeader,
children,
@@ -49,7 +59,8 @@
highlight && 'text-emphasis'
)}
>
{label}
{#if labelPrefix}<span class="font-normal text-secondary">{labelPrefix}</span
>&nbsp;{/if}{label}
</span>
{/snippet}
@@ -63,6 +74,7 @@
disabled={!toggleable}
aria-expanded={toggleable ? expanded : undefined}
>
{@render headerLeft?.()}
{#if shimmer}
<span class="shimmer inline-flex items-center min-w-0">
{@render labelText(false)}
@@ -0,0 +1,238 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { Play, X } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import { enforceDisabledDefaults, resetKeysToast } from '$lib/components/job_args'
import { sendUserToast } from '$lib/utils'
import { getAiChatManager } from './aiChatManagerContext'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { scrollFades } from './scrollFades.svelte'
import type { RunFormDisplay } from './shared'
// Never the imported singleton: submitting has to resolve the pending callback of
// the manager that opened this form, which in a session is a per-pane one.
const aiChatManager = getAiChatManager()
interface Props {
toolCallId: string
runForm: RunFormDisplay
/** `card` caps its own height inside the chat transcript; `pane` fills the preview
* tab it was opened into and lets the fields take the whole height. */
layout?: 'card' | 'pane'
}
let { toolCallId, runForm, layout = 'card' }: Props = $props()
// The chat's workspace, not the globally-active one: a session may be acting on a
// fork, and that is where the job runs — so the pickers and the ephemeral secret
// variables have to resolve there too.
const workspace = $derived(aiChatManager.operatingWorkspace)
// The manager's draft, not a copy of its own: the chat card and the preview pane are two
// views of one form, and moving between them has to keep what was typed. untrack because
// the message is replaced on every patch to the card, and re-seeding from a later copy of
// it would discard those edits.
const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm))
const properties = $derived(draft.schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
let isValid = $state(true)
// Off the manager, not this instance: moving the form between the card and the preview
// panel unmounts it, and a flag that died with it would re-arm both buttons mid-submit.
const submitting = $derived(aiChatManager.isRunFormSubmitting(toolCallId))
let cardNode = $state<HTMLDivElement | undefined>()
// The picker moves while a form sits open, so this is live state, not mount-time. It holds
// off the entrypoint a `dynselect-` argument runs, which does not wait for Run. A password
// field mints at mount, too early to hold off here, so `runThroughForm` gates that instead.
const planMode = $derived(aiChatManager.planModeActive)
onMount(() => {
void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' }))
})
const fades = scrollFades()
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
// The two hosts stand on different surfaces — the chat card on the tool call's own, the
// preview tab on the raised one — and a fade has to end in the colour behind it.
const fadeTo = $derived(
layout === 'pane'
? 'bg-gradient-to-t from-surface-tertiary via-surface-tertiary/60 to-transparent'
: 'bg-gradient-to-t from-surface via-surface/60 to-transparent'
)
async function run() {
if (!isValid) return
// Before processSecretArgs, not after: a card restored from history outlives the
// manager that opened it, so submitting would mint an ephemeral secret variable
// per click and still run nothing.
if (!aiChatManager.isRunFormPending(toolCallId)) {
sendUserToast('This run form is no longer active — ask again to run the script.', true)
return
}
// Ahead of processSecretArgs, which writes ephemeral variables to the workspace: the
// autonomy picker moves while a form sits pending, and the re-gate on the other side
// of the callback runs too late to unmake a write plan mode promised not to do.
if (aiChatManager.planModeActive) {
sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true)
return
}
if (!aiChatManager.beginRunFormSubmit(toolCallId)) return
// Only the disabled fields, and only because `RunForm` does the same before its own
// run: what the card showed is otherwise what runs. Re-filtering it here would delete
// the user's own typing between Run and the job — a free-form field the form gave a
// JSON editor to holds keys no schema names, and they are still theirs.
const { args: enforced, resetKeys } = enforceDisabledDefaults(draft.args ?? {}, draft.schema)
if (resetKeys.length > 0) {
sendUserToast(resetKeysToast(resetKeys))
}
let processed: Record<string, any>
try {
processed = await processSecretArgs(enforced, draft.schema as any, workspace)
} catch (e) {
aiChatManager.endRunFormSubmit(toolCallId)
sendUserToast('Failed to process sensitive args: ' + e, true)
return
}
// The callback can still go away across the processSecretArgs round trip, and by
// then the ephemeral variables exist — say so rather than leaving a dead button.
if (!aiChatManager.handleRunFormSubmit(toolCallId, processed)) {
aiChatManager.endRunFormSubmit(toolCallId)
sendUserToast('This run form is no longer active — ask again to run the script.', true)
}
}
</script>
<!-- The card's heading and its chrome belong to RunScriptCard, which renders this form
as one phase of the same card. The keyboard-scope marker says a form owns the keys
here, so a list's own shortcuts stand down (ItemsList's SKIP_SELECTOR) — it belongs
on this phase only, which is why a settled card drops it with the form. -->
<!-- font-main because the card hosting this in the chat is a tool row, and those are
font-mono throughout: a field label is UI text, and the pane copy of this same form
already reads that way. (Not font-sans — this Tailwind config defines main and mono.) -->
<div
bind:this={cardNode}
class={twMerge('flex flex-col font-main', layout === 'pane' ? 'h-full min-h-0' : 'pt-3')}
data-chat-keyboard-scope="run-args-form"
>
<!-- Only the fields scroll, sized by their content rather than filling the host: the actions
follow the last field of a short form, and a long one scrolls under them rather than
pushing the Run button — and the lines naming what the form dropped — below the fold. -->
<div class={twMerge('relative flex flex-col', layout === 'pane' ? 'min-h-0' : '')}>
<!-- `pl-9` is the rail the pane header's title stands on, its padding plus the icon and
the gap beside it, so the tab reads down one edge. The card has no such rail to meet
and reserves the gutter on both sides instead, which keeps its fields centred. -->
<div
use:fadeContainer
onscroll={measureFades}
class={twMerge(
'overflow-y-auto',
layout === 'pane' ? 'min-h-0 pl-9 pr-3' : 'max-h-[min(28rem,50vh)] px-3'
)}
style={layout === 'pane'
? 'scrollbar-gutter: stable;'
: 'scrollbar-gutter: stable both-edges;'}
>
<div use:fadeContent>
{#if hasArgs}
<!-- Passing `helperScript` is what lets DynamicInput run a job before Run: it
executes the `dynselect-` entrypoint on mount, carrying the other args as proposed,
and Cancel does not undo it. Withheld under plan mode, which promised no writes.
A test run names its draft by code: `deployed` resolves a stale version, or none. -->
<SchemaForm
bind:schema={draft.schema}
helperScript={planMode
? undefined
: runForm.kind === 'test'
? runForm.code && runForm.lang
? { source: 'inline', code: runForm.code, lang: runForm.lang }
: undefined
: { source: 'deployed', path: runForm.path, runnable_kind: 'script' }}
disabled={planMode}
{workspace}
prettifyHeader
bind:isValid
bind:args={draft.args}
/>
{:else}
<p class="text-xs text-secondary">This script takes no arguments.</p>
{/if}
</div>
</div>
<!-- Only what is still below, and only while there is some: the fade the tool cards draw
under their own content, over the scroller rather than inside it. Nothing at the top —
having scrolled down is itself the knowledge that there is more up there. -->
{#if fades.bottom}
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)]',
fadeTo
)}
></div>
{/if}
</div>
<!-- One region with the actions: these lines report on the run the button below launches,
and separating them would read as two subjects. No padding of its own over the fields:
every one of them ends on the room ArgInput keeps for a validation message, and adding
to it would sit the buttons twice as far below the last field as the first label sits
from the top. A form with no fields keeps none of that room, so there it comes back. -->
<div
class={twMerge(
'flex flex-col gap-2 px-3 pb-3',
hasArgs ? '' : 'pt-3',
layout === 'pane' ? 'pl-9' : ''
)}
>
{#if runForm.clearedKeys?.length}
<p class="text-2xs text-secondary">
Sent in a shape this field has no reading of, so it opened empty:
<span class="font-mono">{runForm.clearedKeys.join(', ')}</span>
</p>
{/if}
{#if runForm.resetKeys?.length}
<p class="text-2xs text-secondary">
Disabled by this script, so it will run with its default:
<span class="font-mono">{runForm.resetKeys.join(', ')}</span>
</p>
{/if}
{#if runForm.strippedKeys?.length}
<p class="text-2xs text-secondary">
A file, so it opened empty for you to attach:
<span class="font-mono">{runForm.strippedKeys.join(', ')}</span>
</p>
{/if}
{#if planMode}
<p class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.runFormRefused}</p>
{/if}
<!-- Reject then confirm at the end of the row, as ToolConfirmationFooter puts them: this
is a tool call being validated. Both rest while a submit is in flight — the ephemeral
variables exist by then, and cancelling would settle the call as declined on a run
already starting. Escape stops the turn from here and nowhere else in the form. -->
<div class="flex items-center justify-end gap-2" data-run-form-actions={toolCallId}>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: X }}
disabled={submitting}
onClick={() => aiChatManager.handleRunFormCancel(toolCallId)}
>
Cancel
</Button>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Play }}
disabled={!isValid || submitting || planMode}
onClick={run}
>
Run
</Button>
</div>
</div>
</div>
@@ -0,0 +1,529 @@
<script lang="ts">
import { onMount } from 'svelte'
import { cubicOut } from 'svelte/easing'
import { prefersReducedMotion } from 'svelte/motion'
import { Ban, Loader2, TimerOff } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { Button, Tab, Tabs } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { msToReadableTime } from '$lib/utils'
import JobArgs from '$lib/components/JobArgs.svelte'
import { base } from '$lib/base'
import { getAiChatManager } from './aiChatManagerContext'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import RunArgsFormDisplay from './RunArgsFormDisplay.svelte'
import ToolContentDisplay from './ToolContentDisplay.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import { scrollFades } from './scrollFades.svelte'
import { isActiveRunForm, MAX_LOG_LENGTH, type ToolDisplayMessage } from './shared'
const aiChatManager = getAiChatManager()
interface Props {
message: ToolDisplayMessage
}
let { message }: Props = $props()
const runForm = $derived(message.runForm!)
// The loop is parked on the form and nothing has run yet: the card is the form.
const pending = $derived(isActiveRunForm(message))
const chatJob = $derived(
aiChatManager.backgroundJobs.find((j) => j.toolCallId === message.tool_call_id)
)
// Declining the form, stopping the turn and cancelling the job all land here, and none
// of them is a failure: the run stopped because someone said so.
const canceled = $derived(
Boolean(message.declinedByUser) || Boolean(runForm.canceled) || chatJob?.status === 'canceled'
)
const failed = $derived(Boolean(message.error) && !canceled)
// A cancelled form never reached a job, so it has no logs and no outcome to offer.
const ran = $derived(
Boolean(runForm.started) || Boolean(message.logs) || message.result !== undefined || !!chatJob
)
// A run can outlive the turn that started it, so "the tool call returned" is not
// "the run finished": a detached job keeps the card in its running state until
// the background poller lands an outcome on it or the tray sees the job end.
const settled = $derived(
!pending &&
!message.isLoading &&
(message.result !== undefined ||
failed ||
canceled ||
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
)
const running = $derived(!pending && !settled)
const parameters = $derived(
message.parameters && typeof message.parameters === 'object' ? message.parameters : {}
)
const logs = $derived(typeof message.logs === 'string' ? message.logs : '')
const logLineCount = $derived(logs.trim() ? logs.trimEnd().split('\n').length : 0)
// What the job has streamed of its result so far. Only ever set while it runs: the
// terminal patch clears it, so a settled card reads its outcome off `result` alone.
const resultStream = $derived(
typeof message.resultStream === 'string' ? message.resultStream : ''
)
const streaming = $derived(running && resultStream.length > 0)
// The card stores its result as text (see formatResult), so read it back into a
// value DisplayResult can render: a markdown, table or image result is what the
// pretty view buys. A string that happens to be JSON parses back as JSON, and the
// text it was stored as is one toggle away in the raw view.
const resultValue = $derived.by(() => {
if (message.result === undefined) return undefined
if (typeof message.result !== 'string') return message.result
try {
return JSON.parse(message.result)
} catch {
return message.result
}
})
// The row is the card's whole heading, in the tense the call is in: a run cancelled
// before it started never ran, so it is still the thing that was going to be run. A
// test says so, since what it ran is the draft rather than what is deployed.
const verbs = $derived(
runForm.kind === 'test'
? { present: 'Testing', past: 'Tested', future: 'Test' }
: { present: 'Running', past: 'Ran', future: 'Run' }
)
// What the script is called on its own page and in the picker, so the row names the thing
// that ran rather than where it is filed. Not every script has one, so the path stays the
// fallback — and stays on the preview chip either way, since two folders can hold one name.
const runnableName = $derived(runForm.summary || runForm.path)
const verb = $derived(running ? verbs.present : settled && ran ? verbs.past : verbs.future)
// Being cancelled is an outcome like any other, and it is the one the card has to say out
// loud: nothing came back, so no other tab can carry it.
const outcomeTab = $derived(
failed ? 'Error' : canceled ? (ran ? 'Cancelled' : 'Not run') : 'Result'
)
const cancelReason = $derived(
ran
? 'This run was cancelled while the script was running.'
: 'This run was cancelled before the script started.'
)
// Streaming opens the tab early: the result is already arriving, and one that appeared
// only at the end would hide the thing the user is waiting to read.
const hasOutcome = $derived(settled || streaming)
const tabs = $derived([
{ value: 'input', label: 'Inputs' },
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
...(hasOutcome ? [{ value: 'outcome', label: outcomeTab }] : [])
])
// Keyed by call id: this instance is reused when the message at its index changes, so a
// bare flag would hand one card's view to whichever run lands in the slot next. Unset
// until a tab is clicked and never cleared, so the run stops following itself once
// the user has steered the card.
let userTab = $state<{ id: string; value: string } | undefined>(undefined)
let jsonView = $state<{ id: string; on: boolean } | undefined>(undefined)
const steered = $derived(userTab?.id === message.tool_call_id ? userTab.value : undefined)
const rawView = $derived(jsonView?.id === message.tool_call_id ? jsonView.on : false)
// However the run landed, that is what the card opens on.
const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input')
const activeTab = $derived(steered && tabs.some((t) => t.value === steered) ? steered : autoTab)
// Keyed by call id: a bare flag would carry one card's collapse onto the next message
// reusing this instance. Open by default, since the run is what was asked for.
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : true)
// The panel mounts the chat's own form on this call, so the card must not mount a second
// one: two views binding the one draft would each reorder the schema SchemaForm edits in
// place. Only the form is exclusive. A run shows in both, and collapsing the row or
// closing the tab is the user's own way out of seeing it twice.
const formInPreview = $derived(
pending && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false)
)
// The three moments a run moves the card — a tab arrives, the selection follows it, the body
// changes under it — are staggered rather than landing in one frame, which is what makes the
// card followable. A tab already on screen when the card mounts never arrived, so a settled
// call restored from history renders its strip at rest.
let liveStrip = $state(false)
onMount(() => (liveStrip = true))
function growIn(node: HTMLElement, { live }: { live: boolean }) {
const width = node.getBoundingClientRect().width
return {
duration: live && !prefersReducedMotion.current ? 170 : 0,
easing: cubicOut,
css: (t: number) => `width:${t * width}px; opacity:${t}`
}
}
/** The body fades in over the time the bar takes to travel, so the two halves of a tab change
* land together rather than one instantly and one over 200ms. No shift with it: the logs open
* pinned to the end of their own scroll, which clips one, and a pane that moves on two tabs
* out of three reads as a glitch rather than as direction. */
function enterPane(_node: HTMLElement) {
return {
duration: liveStrip && !prefersReducedMotion.current ? 200 : 0,
easing: cubicOut,
css: (t: number) => `opacity:${t}`
}
}
let bodyEl: HTMLDivElement | undefined = $state()
const fades = scrollFades()
// The arguments table brings its own surface, so a fade ending in the card's would seam
// against it; logs and a result stand on the body's own ground and take it cleanly.
const fadeBody = $derived(!rawView && (activeTab === 'logs' || activeTab === 'outcome'))
// One scroll region serves every tab, so a switch has to place it: logs open on their
// end, which is where a run is read from, and everything else on its start — otherwise
// the tab opened after the logs would begin part-way down its own content.
$effect(() => {
void rawView
if (!bodyEl) return
bodyEl.scrollTop = activeTab === 'logs' && !rawView ? bodyEl.scrollHeight : 0
})
// And stay on the end while the job writes: a log stream the user has to scroll to
// read is not following the run.
$effect(() => {
void logs
if (!bodyEl || rawView || activeTab !== 'logs' || !running) return
bodyEl.scrollTop = bodyEl.scrollHeight
})
// Ticks only while this card has a job in flight, so a transcript of settled runs
// keeps no timers at all.
let now = $state(Date.now())
$effect(() => {
if (!running || !chatJob) return
const timer = setInterval(() => (now = Date.now()), 500)
return () => clearInterval(timer)
})
const elapsed = $derived(chatJob ? msToReadableTime(now - chatJob.createdAt, 2) : '')
const duration = $derived(
chatJob?.durationMs !== undefined ? msToReadableTime(chatJob.durationMs, 2) : ''
)
// The jobs bar's status hues, darkened: those are drawn for a tinted ground and land
// brighter than the name on this transparent one. Per-hue steps because the ramp is not
// uniform, and ok borrows emerald because green skips that weight.
const statusClass = $derived.by(() => {
// The card outlives its job and sometimes precedes it, so the states only it knows about
// read off its own flags rather than off a status no job is there to report.
if (canceled) return 'text-tertiary'
if (failed) return 'text-red-800 dark:text-red-300'
if (!ran) return 'text-tertiary'
switch (chatJob?.status) {
case 'running':
return 'text-blue-800 dark:text-blue-200'
case 'suspended':
return 'text-violet-800 dark:text-violet-300'
case 'queued':
case 'scheduled':
return 'text-orange-800 dark:text-orange-300'
case 'failure':
return 'text-red-800 dark:text-red-300'
case 'success':
return 'text-green-700 dark:text-emerald-400'
default:
return settled ? 'text-green-700 dark:text-emerald-400' : 'text-blue-800 dark:text-blue-200'
}
})
// How long it took, which is the one thing the colour cannot say. A run that never started
// has no time to give, so its outcome takes the slot — as a word, never "Not run", which
// stutters against the "Run <name>" label beside it.
const outcome = $derived(failed ? 'Failed' : canceled ? 'Cancelled' : 'Done')
const statusTime = $derived(running ? elapsed : duration || outcome)
// What the preview button opens changes with the card: the form while the call is still
// waiting on one, the run once a job exists. Neither, and there is nothing to open, so
// the button is not drawn at all — a form has nowhere to go outside a session, and a
// call cancelled before Run never became a run.
const previewTarget = $derived(
pending
? aiChatManager.openRunForm
? ('form' as const)
: undefined
: chatJob
? ('run' as const)
: undefined
)
const previewTitle = $derived(
previewTarget === 'form'
? `Open this form in the preview panel: ${runForm.path}`
: aiChatManager.openRunInPreview
? `Open this run in the preview panel: ${runForm.path}`
: `Open this run in a new tab: ${runForm.path}`
)
function openPreview() {
const label = runnableName
if (previewTarget === 'form') {
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
return
}
if (!chatJob) return
// Outside a session there is no panel, so the run opens where the jobs tray sends it.
if (aiChatManager.openRunInPreview) {
aiChatManager.openRunInPreview({ jobId: chatJob.jobId, workspace: chatJob.workspace, label })
} else {
window.open(
`${base}/run/${chatJob.jobId}?workspace=${chatJob.workspace}`,
'_blank',
'noreferrer'
)
}
}
</script>
<!-- One readout rather than a badge beside a number: the colour says how the run went and the
text how long it took, which is how the rest of the chat states a status. While it runs
that number is still moving. `font-medium` because the row is a button and the base layer
sets those semibold, which would leave this the one bold word in the header. -->
{#snippet status()}
{#if !pending}
<span class={twMerge('shrink-0 whitespace-nowrap text-2xs font-medium', statusClass)}>
{statusTime}
</span>
{/if}
{/snippet}
<!-- The chip every other tool row opens its preview with, pointed at this call: the form
on its way in, the run on its way out. Not a toggle — pressing it again focuses the
tab it already opened. The row's only control, as on every other tool call. -->
{#snippet previewChip()}
<ToolPreviewCard
card={{ kind: 'script', path: runForm.path }}
title={previewTitle}
onOpen={openPreview}
kindIcon={false}
/>
{/snippet}
<!-- scroll-mb clears the chat's sticky "Waiting for your input" chip so the mount
scrollIntoView of the form below leaves the Run button uncovered. -->
<!-- The runnable's name is the subject of the row and the verb is grammar, so the name carries
the weight and the verb stays quiet. Held one step under the card's headings, since this is a
row in a transcript rather than a title. font-main because a name is UI text: the row around
it is font-mono, right for a path, wrong here. -->
<ChatCollapsibleCard
label={runnableName}
labelPrefix={verb}
{expanded}
onToggle={() => (toggled = { id: message.tool_call_id, open: !expanded })}
headerLeft={status}
headerRight={previewTarget ? previewChip : undefined}
class="scroll-mb-8"
labelClass="font-main font-medium text-primary"
contentClass="p-0 overflow-hidden"
>
{#if formInPreview}
<div class="px-3 py-2 text-2xs leading-4 text-hint">
These inputs are open in the preview panel.
</div>
{:else if pending}
<RunArgsFormDisplay toolCallId={message.tool_call_id} {runForm} />
{:else}
<!-- One region holding the strip and the body, fixed so the card is the same size on every
tab — a cap would not do it, since the body is a scroll region and a max-height
silently beats flex-grow. The raw view takes that height as a floor instead: its
blocks scroll on their own, as an ordinary tool call's do, so a scroller around them
would be one too many. -->
<div class={twMerge('relative flex flex-col', rawView ? 'min-h-[20rem]' : 'h-[20rem]')}>
<!-- The tabs go in raw view — they name the parts of the body, and the raw call is not
one of them — while the strip stays, since the JSON toggle lives there. Hence its
fixed height: a row sized by its contents would step every time the tabs leave, and
the tighter Tab padding below is what fits a label inside that height. -->
<Tabs
selected={activeTab}
on:selected={(e) => (userTab = { id: message.tool_call_id, value: e.detail })}
class="h-8 px-3 font-main"
wrapperClass="shrink-0"
slidingIndicator
>
{#if !rawView}
{#each tabs as tab (tab.value)}
<!-- The tab widens in first and the bar follows it, because a run adds its tabs as
it produces them: landing the selection on a tab in the frame it appears reads
as one unexplained jump. `border-b-0` because the bar is the selection now.
Size only: Tab's own colour and weight mark the selection, and this class
lands after them in its twMerge, so a colour here would silently win. -->
<span class="inline-flex overflow-hidden" in:growIn={{ live: liveStrip }}>
<Tab
value={tab.value}
label={tab.label}
class="border-b-0 py-0.5 text-2xs leading-4"
exact
>
{#snippet extra()}
{#if tab.value === 'logs' && logLineCount > 0}
<span class="text-2xs text-hint">{logLineCount}</span>
{/if}
{/snippet}
</Tab>
</span>
{/each}
{/if}
<div class="ml-auto flex items-center pl-2">
<Toggle
checked={rawView}
on:change={(e) => (jsonView = { id: message.tool_call_id, on: e.detail })}
size="2xs"
options={{ right: 'JSON', rightTooltip: 'Show this call as raw JSON' }}
lightMode
/>
</div>
</Tabs>
<!-- Logs sit on the softer surface, the way program output is shown everywhere else.
The raw view keeps the card's own, as an ordinary tool call has it. -->
<div
bind:this={bodyEl}
use:fades.container
onscroll={fades.measure}
class={twMerge(
'min-h-0 flex-1 px-3 py-2',
rawView ? '' : 'overflow-auto',
!rawView && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
)}
>
<!-- min-h-full rather than h-full: the states that centre themselves need the height,
and a box that always filled it would measure as never scrollable. -->
<div use:fades.content class="flex min-h-full flex-col">
{#if rawView}
<div class="space-y-3">
<!-- Each block scrolls on its own, so each fades on its own. -->
<ToolContentDisplay title="Parameters" content={message.parameters} showFade />
<ToolContentDisplay title="Logs" content={message.logs} tail showFade />
<ToolContentDisplay
title="Result"
content={message.result}
error={message.error}
showFade
/>
</div>
{:else}
<!-- Keyed on the tab so the body arrives rather than cuts. One region serves every
tab, so only the incoming pane moves: overlapping them would ask this scroller
to hold two at once. -->
{#key activeTab}
<div class="flex min-h-full flex-1 flex-col" in:enterPane>
{#if activeTab === 'input'}
<!-- What the runs page shows a finished job's arguments as: the operator has already
read this table. The job id is what lets it fetch arguments too big to have been
persisted with the card. -->
<JobArgs
args={parameters}
id={chatJob?.jobId}
workspace={chatJob?.workspace}
disableExpand
/>
{:else if activeTab === 'logs'}
{#if logs.trim()}
{#if logs.length >= MAX_LOG_LENGTH}
<p class="mb-1 text-2xs text-tertiary">
Tail of the logs, the last {MAX_LOG_LENGTH} characters.
</p>
{/if}
<pre class="whitespace-pre-wrap break-words font-mono text-2xs text-primary"
>{logs}</pre
>
{:else}
<p class="text-2xs text-tertiary">No logs yet.</p>
{/if}
{#if running}
<div class="mt-1 flex items-center gap-1.5 text-2xs text-tertiary">
<Loader2 class="h-3 w-3 animate-spin" />
streaming
</div>
{/if}
{:else if failed}
<pre
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
>{message.error}</pre
>
{:else if streaming}
<!-- The same renderer as a landed result, handed the partial: it is the one that
knows how to show a result arriving in pieces. -->
<DisplayResult
result={undefined}
result_stream={resultStream}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
disableExpand
hideAsJson
/>
{:else if resultValue !== undefined}
<!-- The run page's own renderer, not a second one invented for the chat: it handles
markdown, tables, images and deep nesting without the card guessing at the
shape, and reaches the job through jobId/workspace for an S3 preview.
`disableExpand` drops its toolbar and `hideAsJson` its Pretty/JSON switch,
which the row already owns. -->
<DisplayResult
result={resultValue}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
disableExpand
hideAsJson
/>
{:else if canceled}
<!-- All that is left to render is the fact itself: a form cancelled before Run
never reached a job, so there is no result the way a cancelled run has one. -->
<div
class="flex flex-1 flex-col items-center justify-center gap-1.5 px-4 text-center"
>
<Ban class="h-4 w-4 text-tertiary" />
<p class="text-2xs font-medium leading-4 text-secondary">{cancelReason}</p>
{#if !ran}
<p class="text-2xs leading-4 text-tertiary">
The inputs it would have run with are on the Inputs tab.
</p>
{/if}
</div>
{:else}
<p class="text-2xs text-tertiary">This run returned no result.</p>
{/if}
</div>
{/key}
{/if}
</div>
</div>
<!-- Over the body, not inside it: what is still below fades out, and nothing at the top,
as on the form and on the tool cards. Two layers on the logs, whose ground is the
card's surface with the softer one at half strength over it — one gradient would end
on the wrong colour and leave a band at the very edge. -->
{#if fades.bottom && fadeBody}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)] bg-gradient-to-t from-surface via-surface/60 to-transparent"
></div>
{#if activeTab === 'logs'}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)] bg-gradient-to-t from-surface-secondary/50 via-surface-secondary/30 to-transparent"
></div>
{/if}
{/if}
</div>
{#if running && chatJob}
<!-- Where the form keeps its own actions, so the button that stops a run and the one
that starts it sit in the same corner of the same card. The run page's own cancel
button, down to the icon: the operator has already pressed this one. -->
<div class="flex justify-end border-t border-border-light px-3 py-2">
<Button
variant="accent"
unifiedSize="sm"
destructive
startIcon={{ icon: TimerOff }}
title="Cancel the script"
onClick={() => aiChatManager.cancelJob(chatJob.jobId)}
>
Cancel
</Button>
</div>
{/if}
{/if}
</ChatCollapsibleCard>
@@ -1,6 +1,7 @@
<script lang="ts">
import { Loader2, Copy, Check } from 'lucide-svelte'
import { TOOL_PRETTIFY_MAP } from './shared'
import { scrollFades } from './scrollFades.svelte'
interface Props {
title: string
@@ -12,6 +13,9 @@
streaming?: boolean
toolName?: string
showFade?: boolean
/** Open on the end of the content instead of its start, and stay there as it grows.
* For logs, whose last lines are the ones being looked for. */
tail?: boolean
}
let {
@@ -23,7 +27,8 @@
showWhileLoading = true,
streaming = false,
toolName,
showFade = false
showFade = false,
tail = false
}: Props = $props()
let copied = $state(false)
@@ -75,31 +80,17 @@
}
}
// Only draw the bottom fade when the content actually overflows and the
// user hasn't scrolled to the bottom. `showFade` is the parent's intent;
// `canScrollDown` is the live measurement on the inner scroll container.
let scrollEl: HTMLDivElement | undefined = $state()
let canScrollDown = $state(false)
function updateCanScrollDown() {
if (!scrollEl) {
canScrollDown = false
return
}
canScrollDown = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight > 1
}
// Only draw the bottom fade when the content actually overflows and the user hasn't
// scrolled to the bottom. `showFade` is the parent's intent; `fades.bottom` is the live
// measurement on the inner scroll container, which catches streaming JSON growing past
// max-h-28 as well as the first paint.
const fades = scrollFades()
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
// Mount-time + content-change measurement via ResizeObserver. The
// observer fires on initial observe (catches first paint) and on every
// size change of the scroll container or its content (catches streaming
// JSON growing past max-h-28). User scrolls fire `onscroll` directly.
let scroller = $state<HTMLDivElement | undefined>()
$effect(() => {
if (!scrollEl) return
updateCanScrollDown()
const ro = new ResizeObserver(updateCanScrollDown)
ro.observe(scrollEl)
const inner = scrollEl.firstElementChild
if (inner) ro.observe(inner)
return () => ro.disconnect()
void content
if (tail && scroller) scroller.scrollTop = scroller.scrollHeight
})
</script>
@@ -136,17 +127,18 @@
{:else if hasContent}
<div class="relative">
<div
bind:this={scrollEl}
onscroll={updateCanScrollDown}
bind:this={scroller}
use:fadeContainer
onscroll={measureFades}
class="overflow-x-auto max-h-28 overflow-y-auto"
>
<pre class="text-2xs text-primary whitespace-pre-wrap"
<pre use:fadeContent class="text-2xs text-primary whitespace-pre-wrap"
>{formatJson($state.snapshot(content))}</pre
>
</div>
{#if showFade && canScrollDown}
{#if showFade && fades.bottom}
<div
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-surface via-surface/70 via-surface/40 to-transparent"
class="absolute bottom-0 left-0 right-0 h-[min(2.5rem,35%)] pointer-events-none bg-gradient-to-t from-surface via-surface/70 to-transparent"
></div>
{/if}
</div>
@@ -37,6 +37,7 @@
import ToolMessageActions from './ToolMessageActions.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte'
import RunScriptCard from './RunScriptCard.svelte'
import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
@@ -117,6 +118,10 @@
isActiveUserQuestion(message) ? message.userQuestion : undefined
)
// The run card owns this call from the form to whatever settled it, cancelling included:
// the card is the call, and a run the user stopped is not a different kind of thing.
const isRunCard = $derived(Boolean(message.runForm))
// The preview chip sits on the header row (to the right of the tool-call text);
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
const showPreviewChip = $derived(
@@ -140,6 +145,8 @@
<span class="text-2xs text-tertiary truncate">{message.toolName}</span>
{/if}
</div>
{:else if isRunCard}
<RunScriptCard {message} />
{:else if planState}
<!-- Same lean shape as a tool call below: a header row that collapses into the
transcript, with everything else in one box under it. -->
@@ -8,15 +8,26 @@
interface Props {
card: { kind: PreviewCardKind; path: string }
/** Opens something other than the item's own preview — the run card opens the call
* it owns, which is a form before it is a run. */
onOpen?: () => void
title?: string
/** The kind icon says what the chip opens. A card that already names its own runnable
* in the row above has said it, and repeating it there reads as a second subject. */
kindIcon?: boolean
}
let { card }: Props = $props()
let { card, onOpen, title, kindIcon = true }: Props = $props()
const kindLabel = $derived(card.kind === 'raw_app' ? 'app' : card.kind)
let opening = $state(false)
async function open() {
if (opening) return
if (onOpen) {
onOpen()
return
}
opening = true
try {
await runToolDisplayAction(openItemPreviewAction(card.kind, card.path))
@@ -30,9 +41,11 @@
variant="default"
unifiedSize="2xs"
disabled={opening}
title="Open {kindLabel} preview: {card.path}"
title={title ?? `Open ${kindLabel} preview: ${card.path}`}
onClick={open}
startIcon={{ icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }}
startIcon={kindIcon
? { icon: RowIcon as unknown as IconType, props: { kind: card.kind, size: 12 } }
: undefined}
endIcon={{ icon: PanelRight }}
wrapperClasses="shrink-0"
>
@@ -88,6 +88,19 @@ const CATALOG = [
required: ['workspace', 'hash']
}
},
{
name: 'runScriptByPath',
description: 'Run script by path',
instructions: 'Trigger a run of a deployed script',
path: '/w/{workspace}/jobs/run/p/{path}',
method: 'POST',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
required: ['workspace', 'path']
},
body_schema: { type: 'object', additionalProperties: true }
},
{
name: 'runFlowByPath',
description: 'Run flow by path',
@@ -178,6 +191,19 @@ describe('call_api_get', () => {
expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash')
})
// Left reachable, this endpoint is the way around the argument form: it runs the
// deployed script on the model's arguments, unstripped and unshown.
it('refuses a deployed script run, pointing at run_script', async () => {
const called = await run('call_api_endpoint', { name: 'runScriptByPath' })
expect(called.error).toContain('run_script')
expect(called.success).toBe(false)
// And it is gone from search, so the model is redirected before it ever calls.
const search = await run('search_api_endpoints', { query: 'run deployed script' })
expect(search.matches.map((m: any) => m.name)).not.toContain('runScriptByPath')
expect(search.covered_by_dedicated_tools?.join(' ')).toContain('run_script')
})
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
const result = await run('call_api_get', { name })
@@ -36,6 +36,7 @@ const COVERED_ENDPOINTS: Record<string, string> = {
listFlows: 'list_workspace_items (it includes your drafts)',
listResource: 'list_workspace_items (it includes your drafts)',
listSchedules: 'list_workspace_items (it includes your drafts)',
runScriptByPath: 'run_script (it shows the user an argument form to confirm)',
deleteScriptByPath: 'delete_workspace_item',
deleteScriptByHash: 'delete_workspace_item',
deleteFlowByPath: 'delete_workspace_item',
@@ -82,12 +82,21 @@ vi.mock('$lib/gen', async () => {
runScriptPreview: vi.fn(async () => 'job-script-preview'),
runFlowPreview: vi.fn(async () => 'job-flow-preview'),
runFlowByPath: vi.fn(async () => 'job-flow-by-path'),
runScriptByPath: vi.fn(async () => 'job-script-by-path'),
getJob: vi.fn(async () => ({
type: 'CompletedJob',
success: true,
result: { ok: true },
logs: 'test logs'
})),
// What every job wait polls first; unmocked it reaches the real client and the
// wait never returns. Answers completed, so one tick settles the job.
getJobUpdates: vi.fn(async () => ({
completed: true,
running: false,
new_logs: 'test logs',
log_offset: 'test logs'.length
})),
getJobLogs: vi.fn(async () => 'job log line 1\njob log line 2'),
listJobs: vi.fn(async () => [
{
@@ -294,6 +303,12 @@ vi.mock('$lib/gen', async () => {
}
})
// Minting reaches the API and is covered in secretArgUtils.test.ts; what matters here is that a
// run the posture answers goes through it and starts on what came back.
vi.mock('$lib/components/secretArgUtils', () => ({
processSecretArgs: vi.fn(async (args: Record<string, any>) => args)
}))
vi.mock('./rawAppBundlerBridge', () => ({
bundleRawAppDraft: vi.fn(async () => ({
js: 'bundled js',
@@ -349,6 +364,7 @@ import {
VariableService
} from '$lib/gen'
import { superadmin, userStore, usersWorkspaceStore } from '$lib/stores'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import { clearWorkspaceRoleCache } from '$lib/user'
import { get } from 'svelte/store'
import type { Tool, ToolCallbacks } from '../shared'
@@ -368,7 +384,10 @@ function getBackendDraft<V = any>(kind: string, path: string, _opts?: unknown):
const toolCallbacks: ToolCallbacks = {
setToolStatus: vi.fn(),
removeToolStatus: vi.fn()
removeToolStatus: vi.fn(),
// Every host that can run a script mounts the form, so the default answers it with what it
// opened with. A test meaning to exercise a host without one overrides this with undefined.
requestRunArgs: async (_toolId, form) => form.args
}
function getGlobalTool(name: string): Tool<{}> {
@@ -4318,8 +4337,7 @@ describe('global AI tools', () => {
const result = await withCompletedTestJob(() =>
callGlobalTool('test_run_script', {
path: 'f/scripts/draft-test',
args: { name: 'Ada' }
path: 'f/scripts/draft-test'
})
)
@@ -4328,7 +4346,7 @@ describe('global AI tools', () => {
requestBody: {
path: 'f/scripts/draft-test',
content,
args: { name: 'Ada' },
args: {},
language: 'bun'
}
})
@@ -4347,8 +4365,7 @@ describe('global AI tools', () => {
await withCompletedTestJob(() =>
callGlobalTool('test_run_script', {
path: 'f/scripts/deployed-test',
args: { name: 'Grace' }
path: 'f/scripts/deployed-test'
})
)
@@ -4361,12 +4378,378 @@ describe('global AI tools', () => {
requestBody: {
path: 'f/scripts/deployed-test',
content: 'def main(name):\n return name',
args: { name: 'Grace' },
args: {},
language: 'python3'
}
})
})
// A test run meets the same card as a deployed one, so what previews is what the form
// submitted — not what the model proposed.
it('test_run_script previews the arguments the form submitted', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/formed-test',
summary: 'Formed test script',
content: 'export async function main(name: string) {}',
language: 'bun',
schema: { properties: { name: { type: 'string' } } }
} as any)
let opened: Record<string, any> | undefined
let kind: string | undefined
let helperSource: { code?: string; lang?: string } | undefined
await withCompletedTestJob(() =>
callGlobalTool(
'test_run_script',
{ path: 'f/scripts/formed-test', args: { name: 'Ada' } },
{
...toolCallbacks,
requestRunArgs: async (_toolId, form) => {
opened = form.args
kind = form.kind
helperSource = { code: form.code, lang: form.lang }
return { name: 'Grace' }
}
}
)
)
expect(opened).toEqual({ name: 'Ada' })
// Drives the card's tense: a test says it tested, not that it ran.
expect(kind).toBe('test')
// The draft itself, so a dynselect field offers the options this code returns rather
// than the deployed version's — which is stale, or absent for a draft never deployed.
expect(helperSource).toEqual({
code: 'export async function main(name: string) {}',
lang: 'bun'
})
expect(JobService.runScriptPreview).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/formed-test',
content: 'export async function main(name: string) {}',
args: { name: 'Grace' },
language: 'bun'
}
})
})
// The bypass posture answers a run form as it answers any other confirmation, for a
// deployed run as much as a test. The card must never render one first: a form nobody
// will fill in is attached already settled, so no field is ever mounted, and the schema
// it would have built them from never reaches the transcript.
it('mounts no field on either run form under yolo', async () => {
const script = {
path: 'f/scripts/yolo',
content: 'export async function main(name: string) {}',
language: 'bun',
schema: { properties: { name: { type: 'string' } } }
} as any
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script)
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script)
const statuses: any[] = []
const requestRunArgs = vi.fn(async (_toolId: string, form: any) => form.args)
const yolo = {
...toolCallbacks,
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs
}
await withCompletedTestJob(() =>
callGlobalTool('test_run_script', { path: 'f/scripts/yolo', args: { name: 'Ada' } }, yolo)
)
const testForm = statuses.find((s) => s.runForm)?.runForm
expect(testForm.submitted).toBe(true)
// Nothing is left to render it, and a card carrying one persists it forever.
expect(testForm.schema).toBeUndefined()
// Told the form is already answered, or the loop parks on a card with no fields.
expect(requestRunArgs.mock.calls[0][2]).toEqual({ autoAccepted: true })
expect(JobService.runScriptPreview).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: expect.objectContaining({ args: { name: 'Ada' } }) })
)
statuses.length = 0
await withCompletedTestJob(() =>
callGlobalTool('run_script', { path: 'f/scripts/yolo', args: { name: 'Ada' } }, yolo)
)
const deployedForm = statuses.find((s) => s.runForm)?.runForm
expect(deployedForm.submitted).toBe(true)
expect(deployedForm.schema).toBeUndefined()
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { name: 'Ada' } })
)
})
// The posture is the user's standing answer to whether to ask, so a run it answers starts on
// the model's arguments as sent — no default filled in, no required field second-guessed.
// Predicting what a mounted field would hold starts runs the form itself would refuse; the
// schema's own defaults are the worker's job, from the code's signature.
it('sends the model arguments as proposed when the posture answers', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/defaulted',
schema: {
properties: { name: { type: 'string' }, retries: { type: 'number', default: 3 } },
required: ['name', 'retries']
}
} as any)
const statuses: any[] = []
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/defaulted', args: { name: 'Ada' } },
{
...toolCallbacks,
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
)
)
expect(statuses.find((x) => x.runForm)?.runForm.submitted).toBe(true)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { name: 'Ada' } })
)
})
// A disabled field is declared as not the caller's to set, and the posture answering the
// form does not make the model one of the callers it is kept from.
it('holds a disabled default against the model when the posture answers', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/locked',
schema: {
properties: {
name: { type: 'string' },
mode: { type: 'string', disabled: true, default: 'safe' }
}
}
} as any)
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/locked', args: { name: 'Ada', mode: 'destructive' } },
{
...toolCallbacks,
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { name: 'Ada', mode: 'safe' } })
)
// Or the next call proposes the same override again.
expect(result).toContain('disables mode')
})
// With no form there is no PasswordArgInput to turn a proposed secret into a reference, and
// a job's arguments are readable by everyone who can see its run. What starts the job must
// be what came back from the minting, never the proposal.
it('mints a proposed secret into a reference before starting a run the posture answers', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/secret',
schema: { properties: { token: { type: 'string', password: true } }, required: ['token'] }
} as any)
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
token: '$var:u/ada/secret_arg/minted'
}))
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/secret', args: { token: 'hunter2' } },
{
...toolCallbacks,
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
)
)
expect(processSecretArgs).toHaveBeenCalledWith(
{ token: 'hunter2' },
expect.anything(),
expect.anything()
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
)
})
// The bypass is the user's standing answer, not a licence for the host to skip asking:
// a chat with nowhere to put a form still refuses the run under any other posture.
it('run_script refuses a host with no form unless the posture answers for it', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
path: 'f/scripts/noform',
schema: { properties: { name: { type: 'string' } } }
} as any)
const refused = await callGlobalTool(
'run_script',
{ path: 'f/scripts/noform', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: undefined }
)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(refused).toContain('cannot show a run form')
})
// 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.
it('run_script runs on a formless host under yolo', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
path: 'f/scripts/noform-secret',
schema: {
properties: { token: { type: 'string', password: true } },
required: ['token']
}
} as any)
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
token: '$var:u/ada/secret_arg/minted'
}))
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/noform-secret', args: { token: 'hunter2' } },
{
...toolCallbacks,
requestRunArgs: undefined,
shouldAutoAcceptToolConfirmations: () => true
}
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
)
})
// The transcript is re-cloned into IndexedDB on every save, and a form takes as much text
// as the user pastes. What the card stores is bounded; what the job runs is not.
it('run_script stores a marker for oversized arguments but runs them in full', async () => {
const huge = 'x'.repeat(120_000)
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
path: 'f/scripts/big',
content: 'export async function main(blob: string) {}',
language: 'bun',
schema: { properties: { blob: { type: 'string' } } }
} as any)
const statuses: any[] = []
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/big', args: { blob: 'small' } },
{
...toolCallbacks,
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
// The user pastes into the field the model left small: the model's own
// proposal is bounded by what it can emit, this is not.
requestRunArgs: async () => ({ blob: huge })
}
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { blob: huge } })
)
const persisted = statuses.filter((s) => s.parameters !== undefined).at(-1)?.parameters
expect(persisted).toEqual({ reason: 'WINDMILL_TOO_BIG' })
expect(JSON.stringify(statuses)).not.toContain(huge)
})
// A schema with no fields still opens a form: an empty one is still the Run button, and
// that button is the whole confirmation this tool has. Skipping it because there is
// nothing to fill in starts the script with no confirmation at all.
it('test_run_script opens a form and starts no job when the schema declares no field', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({
path: 'f/scripts/noargs-test',
content: 'export async function main() {}',
language: 'bun',
schema: { properties: {} }
} as any)
const cancelled = await callGlobalTool(
'test_run_script',
{ path: 'f/scripts/noargs-test', args: { force_delete: true } },
{ ...toolCallbacks, requestRunArgs: async () => undefined }
)
expect(JobService.runScriptPreview).not.toHaveBeenCalled()
expect(cancelled).toContain('The user cancelled the run form')
// A schema declaring nothing is a form with no field to hold this, and the card says
// as much — so answering it must not send an argument that was never on screen.
const answered = await withCompletedTestJob(() =>
callGlobalTool(
'test_run_script',
{ path: 'f/scripts/noargs-test', args: { force_delete: true } },
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
)
)
expect(JobService.runScriptPreview).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: expect.objectContaining({ args: {} }) })
)
expect(answered).toContain('does not declare force_delete')
})
// A host that answers the form without minting, as the eval harness does by returning the
// proposal verbatim: the job's arguments are readable by everyone who can see its run.
it('mints a secret the host handed back as a literal', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/host-literal',
schema: { properties: { token: { type: 'string', password: true } } }
} as any)
vi.mocked(processSecretArgs).mockImplementationOnce(async () => ({
token: '$var:u/ada/secret_arg/minted'
}))
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/host-literal', args: { token: 'hunter2' } },
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { token: '$var:u/ada/secret_arg/minted' } })
)
})
// The bypass has no form to have shown them either, so the rule holds there too.
it('drops an undeclared argument when the posture answers too', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/noargs-yolo',
schema: { properties: { name: { type: 'string' } } }
} as any)
await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/noargs-yolo', args: { name: 'Ada', force_delete: true } },
{
...toolCallbacks,
shouldAutoAcceptToolConfirmations: () => true,
requestRunArgs: async (_toolId: string, form: any) => form.args
}
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
expect.objectContaining({ requestBody: { name: 'Ada' } })
)
})
it('test_run_flow previews draft flow content by path', async () => {
const modules = [{ id: 'start', value: { type: 'identity' } }]
await callGlobalTool('write_flow', {
@@ -4633,6 +5016,218 @@ describe('global AI tools', () => {
})
})
// The form IS the consent, so a dismissed one must leave the script unrun.
it('run_script starts no job when the user cancels the form', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/deployed',
summary: 'Deployed',
schema: { properties: { name: { type: 'string' } } }
} as any)
const result = await callGlobalTool(
'run_script',
{ path: 'f/scripts/deployed', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async () => undefined }
)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('The user cancelled the run form')
expect(result).toContain('Do not call run_script again')
})
// job_args.test.ts owns what each step of the argument pipeline does; this owns that
// run_script still runs them. Delete a call from runThroughForm and every one of those
// unit tests still passes, so one call has to cross all of them here.
it('run_script puts the proposed arguments through the whole pipeline', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/everything',
schema: {
properties: {
count: { type: 'number' },
ratio: { type: 'number' },
size: { type: 'number' },
token: { type: 'string', password: true },
locked: { type: 'string', default: 'fixed', disabled: true },
doc: { type: 'string', contentEncoding: 'base64' }
}
}
} as any)
const bytes = 'QUJD'.repeat(1024)
const statuses: any[] = []
let shown: Record<string, any> | undefined
let cleared: string[] | undefined
let reset: string[] | undefined
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{
path: 'f/scripts/everything',
args: {
count: '7',
ratio: 'abc',
size: '$var:u/admin/batch_size',
token: 'hunter2',
locked: 'tampered',
doc: bytes,
force_delete: true
}
},
{
...toolCallbacks,
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
requestRunArgs: async (_toolId, form) => {
shown = form.args
cleared = form.clearedKeys
reset = form.resetKeys
// What the user does with the form: attaches the file no model can produce,
// and names a variable for the secret it was not allowed to fill.
return { ...form.args, doc: bytes, token: '$var:u/ada/prod_api_key' }
}
}
)
)
// Coerced, cleared, left alone, reset, dropped and emptied of bytes — every rule reached
// through the tool rather than called directly. The proposed secret is not emptied: it is
// already in the model's own tool call in the same stored record, and PasswordArgInput
// mints whatever the field opens with before the job sees it.
expect(shown).toEqual({
count: 7,
size: '$var:u/admin/batch_size',
token: 'hunter2',
locked: 'fixed'
})
expect(cleared).toEqual(['ratio'])
expect(reset).toEqual(['locked'])
// The bytes belong in the job request and nowhere else: the card is persisted, and a
// file small enough to survive truncation would otherwise reach the model whole.
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/everything',
requestBody: {
count: 7,
size: '$var:u/admin/batch_size',
locked: 'fixed',
doc: bytes,
token: '$var:u/ada/prod_api_key'
}
})
expect(result).toContain('does not declare force_delete')
expect(result).toContain('<file: 3 KB>')
// The bytes are the value; the reference is not, and the run page shows it for this
// same job.
expect(result).not.toContain(bytes)
expect(JSON.stringify(statuses)).not.toContain(bytes)
expect(result).toContain('$var:u/ada/prod_api_key')
expect(JSON.stringify(statuses)).toContain('$var:u/ada/prod_api_key')
// Named, or an emptied field reads as the user having deleted the value and the next
// call proposes the same bytes again.
for (const named of ['ratio', 'doc', 'locked']) expect(result).toContain(named)
})
// The form is its own confirmation, so it never reaches processToolCall's second gate.
// Plan mode can be switched on while it sits open, and the job must not start.
it('run_script starts no job when plan mode is entered while the form is open', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/noargs',
schema: { properties: {} }
} as any)
let planning = false
const result = await callGlobalTool(
'run_script',
{ path: 'f/scripts/noargs', args: {} },
{
...toolCallbacks,
isPlanModeActive: () => planning,
requestRunArgs: async (_toolId, form) => {
planning = true
return form.args
}
}
)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('plan mode is active')
})
// A form that reaches the screen mints a proposed secret on mount, which the gate after
// the user answers is too late to unmake — so plan mode arriving during the fetch counts.
it('run_script opens no form when plan mode is entered during the schema fetch', async () => {
let planning = false
vi.mocked(ScriptService.getScriptByPath).mockImplementationOnce(async () => {
planning = true
return {
path: 'f/scripts/pw',
schema: { properties: { token: { type: 'string', password: true } } }
} as any
})
let formOpened = false
const result = await callGlobalTool(
'run_script',
{ path: 'f/scripts/pw', args: { token: 'hunter2' } },
{
...toolCallbacks,
isPlanModeActive: () => planning,
requestRunArgs: async (_toolId, form) => {
formOpened = true
return form.args
}
}
)
expect(formOpened).toBe(false)
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
expect(result).toContain('plan mode is active')
})
it('run_script runs the arguments the user submitted, not the ones proposed', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/greet',
schema: { properties: { name: { type: 'string' } } }
} as any)
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/greet', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) }
)
)
expect(JobService.runScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/greet',
requestBody: { name: 'Grace' }
})
// The model must not assume its proposal is what ran.
expect(result).toContain('Ran with arguments: {"name":"Grace"}')
})
// The arguments are already in the call this result answers, and every way the form's
// own differ from the proposed ones has its own clause — so an untouched form has
// nothing to name, and naming it anyway pays for the copy on every later iteration.
it('run_script names the arguments only when the user changed them', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
path: 'f/scripts/greet',
schema: { properties: { name: { type: 'string' } } }
} as any)
const result = await withCompletedTestJob(() =>
callGlobalTool(
'run_script',
{ path: 'f/scripts/greet', args: { name: 'Ada' } },
{ ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args }
)
)
expect(result).not.toContain('Ran with arguments')
expect(result).toContain('unedited')
})
it('test_run_step lists nested step ids when a step is not found', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/nested-step-error',
@@ -5323,6 +5918,9 @@ describe('session-only preview tools gating', () => {
expect(names).not.toContain('list_app_runs')
expect(names).not.toContain('search_dom')
expect(names).not.toContain('read_dom')
// Not withheld: without it the side panel's only route to a deployed run is the raw
// endpoint, which confirms an opaque request body instead of the arguments.
expect(names).toContain('run_script')
// other tools are still present
expect(names).toContain('write_script')
})
@@ -5335,6 +5933,7 @@ describe('session-only preview tools gating', () => {
expect(names).toContain('list_app_runs')
expect(names).toContain('search_dom')
expect(names).toContain('read_dom')
expect(names).toContain('run_script')
// The session set is the full globalTools minus capability-gated tools:
// this environment is not Chromium, so take_screenshot is withheld (DOM
// capture is only faithful on Blink). search_dom / read_dom are not gated.
@@ -20,6 +20,7 @@ import {
WebsocketTriggerService
} from '$lib/gen'
import { createTwoFilesPatch } from 'diff'
import { deepEqual } from 'fast-equals'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import { $ScriptLang } from '$lib/gen/schemas.gen'
import type {
@@ -47,6 +48,16 @@ import {
STARTER_RUNNABLE_KEY,
type FrameworkKey
} from '$lib/components/raw_apps/templates'
import {
coerceArgsToSchema,
dropUndeclaredArgs,
enforceDisabledDefaults,
redactFileArgs,
redactSecretArgs,
stripFileArgs
} from '$lib/components/job_args'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import { PLAN_MODE_MESSAGES } from '../planModeMessages'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue'
import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom'
@@ -120,6 +131,7 @@ import {
isHubPath,
type CreatedResourceTriggerKind,
type PreviewCardKind,
type RunFormDisplay,
type Tool,
type ToolCallbacks,
type ToolDisplayAction
@@ -889,7 +901,19 @@ const testRunScriptSchema = z.object({
const testRunScriptToolDef = createToolDef(
testRunScriptSchema,
'test_run_script',
'Execute a preview-style test run of a script by path, preferring draft content when it exists.',
'Execute a preview-style test run of a script by path, preferring draft content when it exists. The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.',
{ strict: false }
)
const runScriptSchema = z.object({
path: z.string().describe('Workspace path of the deployed script to run.'),
args: testRunArgsSchema
})
const runScriptToolDef = createToolDef(
runScriptSchema,
'run_script',
'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real"); otherwise use test_run_script.',
{ strict: false }
)
@@ -1322,7 +1346,7 @@ ${pipelineBullet}
: ' Pass items ("<kind>:<path>" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace'
}, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed.
- For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools use the draft tools and delete_workspace_item instead.
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step they run the draft.
- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script only when the user names the deployed version ("the deployed X", "in production", "for real") a bare "run X" is not that. For run_script, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every one you can infer. runFlowByPath from the API catalog runs a deployed flow without a form: only for a flow the user asked to run deployed.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
- Keep context targeted.${
@@ -3639,12 +3663,31 @@ export const globalTools: Tool<{}>[] = [
const parsed = testRunScriptSchema.parse(ctx.args)
return testRunScriptByPath(parsed, ctx)
},
requiresConfirmation: true,
confirmationMessage: (args) => `Run a test of ${pathLeaf(args?.path, 'the script')}`,
// No requiresConfirmation: the argument form is the confirmation, and the bypass posture
// answers it with what the form opened with — a decision made for the user, so the
// posture's own list has to name it. One thing does run before Run: see the note on
// the form's SchemaForm.
bypassedByAutoAccept: true,
confirmationMessage: 'Run a test of a script',
streamingLabel: 'Preparing the test form...',
queuedLabel: (args) => `Test ${args?.path ?? 'the script'}`,
showDetails: true,
autoCollapseDetails: false
},
{
def: runScriptToolDef,
fn: async (ctx) => {
const parsed = runScriptSchema.parse(ctx.args)
return runDeployedScript(parsed, ctx)
},
// No requiresConfirmation, for the reason test_run_script carries.
bypassedByAutoAccept: true,
confirmationMessage: 'Run a deployed script',
streamingLabel: 'Preparing the run form...',
queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`,
showDetails: true,
autoCollapseDetails: false
},
{
def: testRunFlowToolDef,
fn: async (ctx) => {
@@ -4282,8 +4325,8 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([
/**
* The global tool set for a given chat: the full `globalTools` for a session
* chat, or `globalTools` minus the session-only preview tools for the regular
* global side-panel chat.
* chat, or `globalTools` minus the preview tools for the regular global
* side-panel chat.
*/
export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] {
const tools = sessionPreview
@@ -5116,16 +5159,52 @@ function writeVariableDraft(args: WriteVariableArgs, ctx: WriteDraftCtx): Promis
async function loadScriptForEdit(
path: string,
workspace: string
): Promise<{ content: string; language: ScriptLang; summary?: string }> {
): Promise<{
content: string
language: ScriptLang
summary?: string
schema?: Record<string, any>
}> {
const draft = await getGlobalDraft(workspace, 'script', path)
if (draft) {
if (typeof draft.value !== 'string' || !draft.language) {
throw new Error(`Draft script "${path}" is missing content or language.`)
}
return { content: draft.value, language: draft.language, summary: draft.summary }
return {
content: draft.value,
language: draft.language,
summary: draft.summary,
schema: draft.schema as Record<string, any> | undefined
}
}
const script = await ScriptService.getScriptByPath({ workspace, path })
return { content: script.content, language: script.language, summary: script.summary }
return {
content: script.content,
language: script.language,
summary: script.summary,
schema: script.schema as Record<string, any> | undefined
}
}
/** The fields a test form offers, for code that may never have been deployed. A draft the
* chat wrote carries the schema it inferred at write time; anything else a draft written
* elsewhere, a deployed script whose schema predates an edit is inferred here from the
* content that is about to run, so the form cannot offer a field the code no longer takes. */
async function schemaForTestRun(script: {
content: string
language: ScriptLang
schema?: Record<string, any>
}): Promise<Record<string, any>> {
// Emptily declared is not declared: a stored `properties: {}` means the schema predates
// the arguments the code now takes, so infer rather than offer a form with no fields.
if (Object.keys(script.schema?.properties ?? {}).length > 0) return script.schema!
const schema = emptySchema()
try {
await inferArgs(script.language, script.content, schema)
} catch (e) {
console.error('Failed to infer script schema for the test run form', e)
}
return schema as unknown as Record<string, any>
}
async function editScript(
@@ -5370,30 +5449,324 @@ async function testRunScriptByPath(
args: z.infer<typeof testRunScriptSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
const { workspace } = ctx
const script = await loadScriptForEdit(args.path, workspace)
const testArgs = normalizeTestRunArgs(args.args)
const schema = await schemaForTestRun(script)
return executeTestRun({
jobStarter: () =>
JobService.runScriptPreview({
workspace,
requestBody: {
path: args.path,
content: script.content,
args: testArgs,
language: script.language
}
}),
return runThroughForm(
{
path: args.path,
schema,
summary: script.summary,
kind: 'test',
code: script.content,
lang: script.language,
// Never "deployed" here: the code about to run is the draft the model is still
// writing, and a line telling it to re-read the deployed schema would send it
// to the wrong version.
schemaNoun: 'script',
toolName: 'test_run_script',
proposed: args.args,
startMessage: `Running test for script "${args.path}"...`,
contextName: 'script',
// Its own loop: the model is told to test and iterate, so the posture answers the
// form with what it opened with rather than parking the loop on a card.
autoAcceptable: true,
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
startJob: (submitted) =>
JobService.runScriptPreview({
workspace,
requestBody: {
path: args.path,
content: script.content,
args: submitted,
language: script.language
}
})
},
ctx
)
}
/** The "do not call again" half is load-bearing: without it the model re-proposes the
* call, which re-opens the form the user just dismissed, and Stop becomes their only
* way out. */
const runFormCancelled = (toolName: string) =>
`The user cancelled the run form. The script did NOT run. Do not call ${toolName} again unless the user asks for it.`
/** The model only needs to see what the user changed, and nothing bounds an object or
* array argument the form let them paste into. */
const MAX_SUBMITTED_ARGS_LENGTH = 4000
/** The card's own copy is bounded separately, and far higher: it is what the details pane
* renders, and JobArgs stops rendering the JSON in full at this size regardless. */
const MAX_PERSISTED_ARGS_LENGTH = 100_000
/** One run through an argument form: conform what the model proposed to the schema of the
* version about to run, open the form on it, then run whatever came back. Both tools that
* run a script are this, differing only in where the schema comes from and how the job
* starts so the user meets one card whichever they asked for. */
type FormRunSpec = {
path: string
schema: Record<string, any>
summary?: string
kind: 'run' | 'test'
/** The code a test run is about to preview, so its form can offer the same dynamic-option
* pickers the script editor's test panel does. Omitted for a deployed run, which names a
* path instead. */
code?: string
lang?: ScriptLang
/** How the lines the model reads back name the version this ran: telling it to re-read
* the "deployed schema" of a draft would send it to the wrong code. */
schemaNoun: string
toolName: string
proposed: Record<string, any> | null | undefined
startMessage: string
contextName: 'script' | 'flow'
/** Whether the bypass posture may answer this form with what it opened with. */
autoAcceptable?: boolean
background?: boolean
detachAfterMs?: number
startJob: (submitted: Record<string, any>) => Promise<string>
}
async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<string> {
const { workspace, toolId, toolCallbacks } = ctx
// Asked of the posture, not of the tool: every run tool is auto-acceptable, so a
// host with no form would otherwise run one on the model's arguments alone, in any
// posture. What a bypass answers is a decision the user already made; without it there
// is no consent to be had here and nothing to fall back on.
const postureAnswers = Boolean(
spec.autoAcceptable && toolCallbacks.shouldAutoAcceptToolConfirmations?.(spec.toolName)
)
if (!toolCallbacks.requestRunArgs && !postureAnswers) {
return 'This chat cannot show a run form, so a script cannot be run from here.'
}
// processToolCall gates plan mode once, before the schema fetch, and this form is its own
// confirmation so it never reaches that gate again. Repeated wherever a write follows: a
// mounted field mints on its own, which no later gate can unmake.
const blockedByPlanMode = (): string | undefined => {
if (!toolCallbacks.isPlanModeActive?.()) return undefined
toolCallbacks.onToolBlockedByPlanMode?.()
toolCallbacks.setToolStatus(toolId, {
content: PLAN_MODE_MESSAGES.blockedLabel,
isLoading: false,
isStreamingArguments: false,
error: PLAN_MODE_MESSAGES.blockedResult,
blockedByPlanMode: true
})
return PLAN_MODE_MESSAGES.blockedResult
}
const blockedBeforeForm = blockedByPlanMode()
if (blockedBeforeForm) return blockedBeforeForm
const schema = spec.schema
// Whether to ask is the only question decided here. What a mounted field would hold — a
// default, a synthesised empty, whether Run lights up — is the form's own business: any
// second derivation of it here can start a run the form itself would refuse.
const autoAccepted = postureAnswers
const strippedKeys: string[] = []
const coerced = autoAccepted
? undefined
: coerceArgsToSchema(normalizeTestRunArgs(spec.proposed), schema)
let proposed: Record<string, any>
let resetKeys: string[]
let undeclaredKeys: string[]
if (coerced) {
resetKeys = coerced.resetKeys
undeclaredKeys = coerced.undeclaredKeys
// Left as the model proposed it, minted by the widget the field mounts: a reference put
// here instead would be normalised away by the nested form an object secret renders as.
proposed = stripFileArgs(coerced.args, schema as any, strippedKeys)
} else {
// Both rules hold against every caller, not only the ones a form stands in front of:
// an undeclared argument has no field anywhere, and a disabled one is nobody's to set.
// Without the rest of the coercion, which answers what a mounted widget would show.
const declared = dropUndeclaredArgs(normalizeTestRunArgs(spec.proposed), schema)
undeclaredKeys = declared.undeclaredKeys
const enforced = enforceDisabledDefaults(declared.args, schema)
resetKeys = enforced.resetKeys
// In the widget's stead: with no form there is no PasswordArgInput to turn a proposed
// secret into a reference, and the job's arguments outlive the run.
try {
proposed = await processSecretArgs(enforced.args, schema as any, workspace)
} catch (e) {
const message = `Failed to store the sensitive arguments of "${spec.path}": ${e}`
toolCallbacks.setToolStatus(toolId, {
content: message,
isLoading: false,
isStreamingArguments: false,
error: message
})
return message
}
}
const form: RunFormDisplay = {
path: spec.path,
summary: spec.summary || undefined,
kind: spec.kind,
schema: autoAccepted ? undefined : schema,
code: autoAccepted ? undefined : spec.code,
lang: autoAccepted ? undefined : spec.lang,
submitted: autoAccepted || undefined,
args: proposed,
clearedKeys: coerced?.clearedKeys.length ? coerced.clearedKeys : undefined,
resetKeys: resetKeys.length ? resetKeys : undefined,
strippedKeys: strippedKeys.length ? strippedKeys : undefined
}
// Files only: nothing rewrites `runForm.args` after this, so bytes left in it outlive the
// size guard that covers `parameters`.
const persisted = { ...form, args: redactFileArgs(proposed, schema as any) }
toolCallbacks.setToolStatus(toolId, {
content: autoAccepted
? spec.startMessage
: `Waiting for you to confirm the arguments of "${spec.path}"`,
runForm: persisted,
// Not the raw tool-call arguments: the card settles on what the form opened with.
// Only settles it — the raw proposal still renders while the call streams in.
parameters: persisted.args,
isLoading: true
})
// `form`, not `persisted`: a password field mints from what it opens with.
const submitted = toolCallbacks.requestRunArgs
? await toolCallbacks.requestRunArgs(toolId, form, { autoAccepted })
: proposed
if (!submitted) {
toolCallbacks.setToolStatus(toolId, {
content: `Run of "${spec.path}" cancelled by user`,
isLoading: false,
isStreamingArguments: false,
error: 'Cancelled by user',
declinedByUser: true
})
return runFormCancelled(spec.toolName)
}
const blockedBeforeRun = blockedByPlanMode()
if (blockedBeforeRun) return blockedBeforeRun
// Every job leaves through here, so this is where a sensitive argument becomes a reference:
// the form mints as the user types and the bypass mints in its stead, but a host answering
// the form its own way — the eval harness does — would hand over a literal. Idempotent, so
// the two that already minted pay a walk and no round trip.
let toRun: Record<string, any>
try {
toRun = await processSecretArgs(submitted, schema as any, workspace)
} catch (e) {
const message = `Failed to store the sensitive arguments of "${spec.path}": ${e}`
toolCallbacks.setToolStatus(toolId, {
content: message,
isLoading: false,
isStreamingArguments: false,
error: message
})
return message
}
// The card's details pane must show what ran, not what was proposed. Bytes are marked by
// size because the card is persisted; everything else stands as the run page shows it for
// the same job.
const forCard = redactFileArgs(toRun, schema as any)
// The transcript is re-cloned into IndexedDB on every save and a form carries whatever was
// pasted into it, so past what the pane would render the card reads the arguments off the
// job instead. Only once there is a job to read them from: substituting the marker any
// earlier would leave a run that never started showing nothing but the marker.
const oversized = JSON.stringify(forCard).length > MAX_PERSISTED_ARGS_LENGTH
if (!oversized) {
toolCallbacks.setToolStatus(toolId, { parameters: forCard })
}
const outcome = await executeTestRun({
jobStarter: async () => {
const jobId = await spec.startJob(toRun)
// The form's own submitted flag flips a round trip earlier, when the user presses
// Run; only from here is there a job for a stopped turn to say it left running.
toolCallbacks.markRunFormStarted?.(toolId)
if (oversized) {
toolCallbacks.setToolStatus(toolId, { parameters: { reason: 'WINDMILL_TOO_BIG' } })
}
return jobId
},
workspace,
toolCallbacks,
toolId,
startMessage: `Running test for script "${args.path}"...`,
contextName: 'script',
background: args.background,
detachAfterMs: waitSecondsToDetachMs(args.wait_seconds),
label: args.path
startMessage: spec.startMessage,
contextName: spec.contextName,
actionNoun: spec.kind === 'test' ? 'test' : 'run',
background: spec.background,
detachAfterMs: spec.detachAfterMs,
label: spec.path
})
const schemaNoun = `${spec.schemaNoun} schema`
// Only what the form could make no reading of: a wrong-typed value it can read is
// converted silently, since the field then shows what the run carries and there is
// nothing to report.
const clearedKeys = coerced?.clearedKeys ?? []
const cleared = clearedKeys.length
? `\nThe ${schemaNoun} declares ${clearedKeys.join(', ')}, but you sent ${clearedKeys.length > 1 ? 'them in shapes' : 'it in a shape'} with no reading in the declared ${clearedKeys.length > 1 ? 'types' : 'type'}, so the ${clearedKeys.length > 1 ? 'fields opened' : 'field opened'} empty and the run did not carry ${clearedKeys.length > 1 ? 'them' : 'it'}. Re-read the input schema and match ${clearedKeys.length > 1 ? 'their declared types' : 'its declared type'}.`
: ''
const reset = resetKeys.length
? `\nThe ${schemaNoun} disables ${resetKeys.join(', ')}, so the run used ${resetKeys.length > 1 ? 'their defaults' : 'its default'} rather than the proposed ${resetKeys.length > 1 ? 'values' : 'value'}. Do not propose ${resetKeys.length > 1 ? 'them' : 'it'} again.`
: ''
// Nothing renders these, so the model is the only one who can be told they went nowhere.
const undeclared = undeclaredKeys.length
? `\nThe ${schemaNoun} does not declare ${undeclaredKeys.join(', ')}, so ${undeclaredKeys.length > 1 ? 'they were' : 'it was'} not sent — no run form in Windmill offers a field the schema does not name. Re-read the input schema and use the arguments it declares.`
: ''
// Otherwise an emptied field reads as the user having deleted it, and the next call
// proposes the same bytes again.
const stripped = strippedKeys.length
? `\n${strippedKeys.join(', ')} ${strippedKeys.length > 1 ? 'are file arguments' : 'is a file argument'}, so the form opened ${strippedKeys.length > 1 ? 'them' : 'it'} empty for the user to attach. ${strippedKeys.length > 1 ? 'They are' : 'It is'} theirs to provide, not yours: do not propose ${strippedKeys.length > 1 ? 'them' : 'it'} again.`
: ''
// Redacted for the model alone: what it proposed is already in its own tool call, but a
// secret the user typed into the form would be entering its context here.
const redacted = redactFileArgs(redactSecretArgs(toRun, schema as any), schema as any)
const submittedJson = JSON.stringify(redacted)
const shown =
submittedJson.length > MAX_SUBMITTED_ARGS_LENGTH
? submittedJson.slice(0, MAX_SUBMITTED_ARGS_LENGTH) + '... (truncated)'
: submittedJson
// Naming them costs a copy of arguments already in the call above, and cleared/reset/
// stripped name every way the form's own differ from the proposed ones — so only what
// the user changed is news.
const ran = deepEqual(redacted, proposed)
? 'Ran with the arguments the form opened with, unedited.'
: `Ran with arguments: ${shown}`
return `${ran}${cleared}${reset}${stripped}${undeclared}\n${outcome}`
}
async function runDeployedScript(
args: z.infer<typeof runScriptSchema>,
ctx: WriteDraftCtx
): Promise<string> {
const { workspace } = ctx
// No getDraft: this runs the script as it is live, so the form has to offer the
// inputs the live version accepts and not a draft's.
const script = await ScriptService.getScriptByPath({ workspace, path: args.path })
return runThroughForm(
{
path: args.path,
schema: (script.schema as Record<string, any>) ?? {},
summary: script.summary,
kind: 'run',
schemaNoun: 'deployed',
toolName: 'run_script',
proposed: args.args,
startMessage: `Running "${args.path}"...`,
contextName: 'script',
// Bypassable like a test run: the posture is the user's standing answer, and a form
// it parks on is a card nobody is watching.
autoAcceptable: true,
startJob: (submitted) =>
JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted })
},
ctx
)
}
async function testRunFlowByPath(
@@ -12,6 +12,9 @@ export const PLAN_MODE_MESSAGES = {
/** Sits beside the autonomy picker while plan mode holds. The picker's tooltip carries
* the rest, so this states only the constraint. */
modeNote: 'Read-only',
/** Refuses a pending run form. Its own string because nothing is settled: the form stays
* live, so this names the way out rather than telling the user their run was blocked. */
runFormRefused: 'Plan mode is read-only — switch it off to run this script.',
// One pair for both artifact tools: the fact and the way forward are the same whether the
// model tried to mint the plan or to rewrite it, and the generic refusal above ("put this
// change in your plan") reads as nonsense for a call that writes a document.
@@ -0,0 +1,56 @@
/**
* Live "is there more past this edge" for one scroll region. Measured, not assumed: these
* boxes change height under a still scroll offset a tool result streams in, a dynamic
* field fills its options. Put `container` on the scrolling element with
* `onscroll={measure}`, and `content` on the element inside it whose height moves.
*/
export function scrollFades() {
let node: HTMLElement | undefined = undefined
let top = $state(false)
let bottom = $state(false)
// Built on first attach, never at call time: this runs during component init, where
// ResizeObserver does not exist on the server.
let observer: ResizeObserver | undefined = undefined
function measure() {
if (!node) {
top = false
bottom = false
return
}
top = node.scrollTop > 1
bottom = node.scrollHeight - node.scrollTop - node.clientHeight > 1
}
function observe(el: HTMLElement) {
observer ??= new ResizeObserver(measure)
observer.observe(el)
return {
destroy() {
observer?.unobserve(el)
}
}
}
return {
get top() {
return top
},
get bottom() {
return bottom
},
measure,
container(el: HTMLElement) {
node = el
measure()
const handle = observe(el)
return {
destroy() {
handle.destroy()
node = undefined
}
}
},
content: observe
}
}
@@ -38,7 +38,7 @@ vi.mock('$lib/components/flows/flowTree', () => ({
vi.mock('$lib/gen', () => ({
ScriptService: {},
FlowService: {},
JobService: { getJob: vi.fn() },
JobService: { getJob: vi.fn(), getJobUpdates: vi.fn() },
ScheduleService: {
previewSchedule: vi.fn(),
createSchedule: vi.fn()
@@ -945,6 +945,16 @@ describe('processToolCall', () => {
{ requestConfirmation: vi.fn().mockResolvedValue(false) }
)
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
// A tool that runs its own consent surface (the run form) declines by settling
// the card, then returns normally — that must not read as a successful run.
expect(
await outcomeKeys({
fn: vi.fn(async ({ toolCallbacks, toolId }: any) => {
toolCallbacks.setToolStatus(toolId, { declinedByUser: true })
return 'cancelled'
})
})
).toEqual([['ai_chat', 'tool', 'run_script:declined']])
expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([
['ai_chat', 'tool', 'run_script:blocked_plan_mode']
])
@@ -1272,6 +1282,28 @@ describe('isActiveUserQuestion', () => {
})
})
describe('isActiveRunForm', () => {
const runForm = { path: 'f/a/b', schema: {}, args: { name: 'ada' } }
function toolMessage(overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage {
return {
role: 'tool',
tool_call_id: 'call_r',
content: 'waiting for arguments',
isLoading: true,
runForm,
...overrides
}
}
// Either flag unmounts the card, so the loop must stop waiting on it.
it('is false once submitted or cancelled', async () => {
const { isActiveRunForm } = await import('./shared')
expect(isActiveRunForm(toolMessage())).toBe(true)
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, submitted: true } }))).toBe(false)
expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, canceled: true } }))).toBe(false)
})
})
describe('pendingUserAction', () => {
const toolMessage = (overrides: Partial<ToolDisplayMessage> = {}): ToolDisplayMessage => ({
role: 'tool',
@@ -1289,6 +1321,15 @@ describe('pendingUserAction', () => {
expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation')
})
// A run form asks for arguments rather than a yes/no, but it blocks the loop the
// same way, so the chat reports it as a confirmation.
it('reports an unsubmitted run form as a confirmation', async () => {
const { pendingUserAction } = await import('./shared')
expect(
pendingUserAction([toolMessage({ runForm: { path: 'f/a/b', schema: {}, args: {} } })])
).toBe('confirmation')
})
it('is undefined for a tool the AI is running on its own', async () => {
const { pendingUserAction } = await import('./shared')
expect(pendingUserAction([toolMessage()])).toBe(undefined)
@@ -1365,6 +1406,9 @@ describe('pollJobCompletion detach', () => {
const getJob = vi.mocked(JobService.getJob)
getJob.mockReset()
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
getJobUpdates.mockReset()
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
const cbs = makeCallbacks()
// detachAfterMs 2000 → 2 polls at 1s each, then detach.
@@ -1390,14 +1434,50 @@ describe('pollJobCompletion detach', () => {
const { JobService } = await import('$lib/gen')
const getJob = vi.mocked(JobService.getJob)
getJob.mockReset()
const completed = { type: 'CompletedJob', success: true, result: 42 }
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
getJob.mockResolvedValue(completed as any)
// Still landing on a tick the updates endpoint calls unfinished: the job can
// complete between the two calls, and `getJob` is what says so.
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
getJobUpdates.mockReset()
getJobUpdates.mockResolvedValue({ completed: false, running: true } as any)
const cbs = makeCallbacks()
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
await vi.advanceTimersByTimeAsync(1000)
expect(await promise).toBe(completed)
const landed = await promise
expect(landed).toBe(completed)
// Fetched again with its logs rather than settled on the logless tick fetch,
// which would reach the model as "No logs available".
expect((landed as any).logs).toBe('ran')
} finally {
vi.useRealTimers()
}
})
// Streaming rides on a second endpoint; landing the job must not. A failing updates
// endpoint costs live logs, never the run.
it('returns the completed job with its logs when the updates endpoint fails', async () => {
vi.useFakeTimers()
try {
const { pollJobCompletion } = await import('./shared')
const { JobService } = await import('$lib/gen')
const getJob = vi.mocked(JobService.getJob)
getJob.mockReset()
const completed = { type: 'CompletedJob', success: true, result: 42, logs: 'ran' }
getJob.mockResolvedValue(completed as any)
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
getJobUpdates.mockReset()
getJobUpdates.mockRejectedValue(new Error('updates unavailable'))
const cbs = makeCallbacks()
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any, { detachAfterMs: 15000 })
await vi.advanceTimersByTimeAsync(1000)
const landed = await promise
expect(landed).toBe(completed)
expect((landed as any).logs).toBe('ran')
} finally {
vi.useRealTimers()
}
@@ -1411,6 +1491,9 @@ describe('pollJobCompletion detach', () => {
const getJob = vi.mocked(JobService.getJob)
getJob.mockReset()
getJob.mockResolvedValue({ type: 'QueuedJob', running: true } as any)
const getJobUpdates = vi.mocked(JobService.getJobUpdates)
getJobUpdates.mockReset()
getJobUpdates.mockResolvedValue({ running: true, completed: false } as any)
const cbs = makeCallbacks()
const promise = pollJobCompletion('job1', 'w', 'tool1', cbs as any)
@@ -553,6 +553,53 @@ export function answeredChoices(q: UserQuestionDisplay): string[] | undefined {
return q.selectedChoices ?? (q.selectedChoice ? [q.selectedChoice] : undefined)
}
/** Argument form for a deployed-script run, persisted with the transcript every
* field has to stay plain JSON. */
export type RunFormDisplay = {
path: string
summary?: string
/** What the run is, in the card's own words: a deployed script run, or a preview of the
* draft being written. Only the tense of the row's label turns on it. */
kind?: 'run' | 'test'
/** Of whatever version is about to run: the deployed script, or the draft a test
* previews. Only the rendered form reads it, so it is dropped once one of the flags
* below unmounts that form: kept, every settled card would carry a copy of the schema
* password and file defaults included in history forever. */
schema?: Record<string, any>
/** The draft a test run previews, for the `dynselect-` helper only a deployed helper
* would answer for the wrong version. Set on a test run alone, and dropped with the
* schema once the form unmounts, so no settled card carries a copy of the code. */
code?: string
lang?: ScriptLang
/** Prefill only: the card's `parameters` records what the job started with. */
args: Record<string, any>
/** Proposed arguments emptied because their declared type had no reading of them.
* Named on the card: an empty field is otherwise the caller having sent nothing. */
clearedKeys?: string[]
/** Proposed arguments a disabled field overrode with its default. Named for the same
* reason: the field renders locked, so the value it holds is not the proposed one. */
resetKeys?: string[]
/** File arguments emptied out of the proposal. Named so an empty field reads as the
* caller's value having been removed, not as the field having none. */
strippedKeys?: string[]
/** Either one unmounts the form, so set exactly one, and only once the loop has
* stopped waiting on this card. */
submitted?: boolean
canceled?: boolean
/** The job exists. Distinct from `submitted`, which flips a round trip earlier in
* between, whether the server queued a job is unknown, so a turn stopped there is
* recorded as neither started nor canceled. */
started?: boolean
}
/** What a run form is being filled with while it waits. Held by the chat manager, not by
* the form, so the chat card and the preview pane edit one draft rather than two copies.
* The schema rides along because SchemaForm binds and reorders it. */
export type RunFormDraft = {
args: Record<string, any>
schema: Record<string, any>
}
/** One page hit from a provider-side web search (OpenAI sources carry no title). */
export type WebSearchSource = {
url: string
@@ -565,6 +612,9 @@ export type ToolDisplayMessage = {
content: string
parameters?: any
result?: any
/** What the job has streamed of its result so far, while it is still running.
* Cleared when the job lands: `result` is then the whole of it. */
resultStream?: string
logs?: string
isLoading?: boolean
/** Arguments fully streamed but execution not started (see queuedToolStatus). */
@@ -578,6 +628,7 @@ export type ToolDisplayMessage = {
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
runForm?: RunFormDisplay
webSearchSources?: WebSearchSource[]
/** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */
imageUrl?: string
@@ -653,6 +704,18 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole
)
}
export function isActiveRunForm(message: DisplayMessage | undefined): boolean {
return Boolean(
message &&
message.role === 'tool' &&
message.runForm &&
message.isLoading &&
!message.error &&
!message.runForm.submitted &&
!message.runForm.canceled
)
}
// The loop is parked on the user: an unanswered askUserQuestion, or a tool call
// staged for confirmation. The manager stays `loading` through both, so anything
// rendering progress must ask here first or it reports "the AI is working".
@@ -676,6 +739,12 @@ export function pendingUserActionDetail(
if (isActiveUserQuestion(message)) {
return { action: 'question', toolCallId: message.tool_call_id }
}
// A run form is a confirmation carrying arguments, not a question: it parks the
// turn the same way, but Run or Cancel resolves it and typing never does — so it
// must not claim the answer affordance a pending question offers.
if (isActiveRunForm(message)) {
return { action: 'confirmation', toolCallId: message.tool_call_id }
}
if (message.needsConfirmation && message.isLoading) {
return { action: 'confirmation', toolCallId: message.tool_call_id }
}
@@ -958,6 +1027,9 @@ export async function processToolCall<T>({
}
let result = ''
// A tool that asks for consent itself settles the call as declined or blocked and
// returns normally, so without this both telemeter as successful runs.
let settledInsideTool: 'declined' | 'blocked_plan_mode' | undefined = undefined
try {
result = await callTool({
tools,
@@ -965,10 +1037,17 @@ export async function processToolCall<T>({
args,
workspace: workspaceId,
helpers,
toolCallbacks,
toolCallbacks: {
...toolCallbacks,
setToolStatus: (toolId, status) => {
if (status?.declinedByUser) settledInsideTool = 'declined'
else if (status?.blockedByPlanMode) settledInsideTool = 'blocked_plan_mode'
toolCallbacks.setToolStatus(toolId, status)
}
},
toolId: toolCall.id
})
logToolOutcome('ok')
logToolOutcome(settledInsideTool ?? 'ok')
toolCallbacks.setToolStatus(toolCall.id, {
isLoading: false,
isStreamingArguments: false
@@ -1069,6 +1148,10 @@ export interface Tool<T> {
* is true. */
refuseInPlanMode?: (p: { args: any; helpers: T }) => ToolRejection | undefined
requiresConfirmation?: boolean
/** The tool's own argument form is its confirmation, and the bypass posture answers that
* form so no card is waited on, yet a decision is still being made for the user. The
* list of what the posture bypasses is built from both this and `requiresConfirmation`. */
bypassedByAutoAccept?: boolean
/** Header shown on the confirmation card before the tool runs. Pass a function
* to derive it from the parsed arguments (e.g. name the script being tested). */
confirmationMessage?: string | ((args: any) => string)
@@ -1229,6 +1312,16 @@ export interface ToolCallbacks {
toolId: string,
question: UserQuestionDisplay
) => Promise<string[] | undefined>
/** Park the loop on an argument form and resolve with the args the user submitted, or
* undefined if they cancelled. Wired only where the form can be rendered. `autoAccepted`
* says YOLO already answered it with what it opened with, so there is no card to wait on. */
requestRunArgs?: (
toolId: string,
form: RunFormDisplay,
opts?: { autoAccepted?: boolean }
) => Promise<Record<string, any> | undefined>
/** The submitted form's job is queued. Wired alongside requestRunArgs. */
markRunFormStarted?: (toolId: string) => void
/** Records a workspace item the tool call created/edited/deleted, by its
* canonical (itemKind, storagePath). Session chats wire this to accumulate the
* chat's modified-items mask; the global side-panel chat omits it (no-op). */
@@ -1488,7 +1581,7 @@ export async function buildSchemaForTool(
// Constants for result formatting
const MAX_RESULT_LENGTH = 12000
const MAX_LOG_LENGTH = 4000
export const MAX_LOG_LENGTH = 4000
export const MAX_RUNNABLE_CONTENT_LENGTH = 20000
/** How long a test run is awaited inline before it detaches into the background
@@ -1517,13 +1610,16 @@ export interface TestRunConfig {
detachAfterMs?: number
/** Human label for the jobs tray row (path / step id). Defaults to the job id. */
label?: string
/** Overrides the default "test started, waiting for completion" status while the
/** Overrides the default "started, waiting for completion" status while the
* job runs inline (e.g. an SQL tool shows "SQL running…"). */
runningMessage?: string
/** Noun for the human-facing status strings ("<X> test completed successfully").
* Defaults to `contextName`, which also carries the jobs-tray kind and so cannot
* always name what ran: an app's path runnable queues a flow job. */
/** The item noun in the human-facing status strings ("Flow test completed
* successfully"). Defaults to `contextName`, which also carries the jobs-tray kind
* and so cannot always name what ran: an app's path runnable queues a flow job. */
completionName?: string
/** The action noun in those same strings ("Script run completed successfully").
* Defaults to "test", so a tool running the deployed item for real passes "run". */
actionNoun?: string
/** Custom terminal formatting for the INLINE completion path (callers whose
* result isn't a plain test-run summary, e.g. exec_datatable_sql shaping rows).
* Returns the string handed to the model plus the tool-card patch. When omitted,
@@ -1544,6 +1640,46 @@ export type BackgroundJobFormatter = (job: CompletedJob) => {
card: Partial<ToolDisplayMessage>
}
/** Reads a running job's output incrementally through `getJobUpdates`, the only endpoint
* carrying `new_result_stream` `getJob` returns logs but never the partial result. Each
* reader keeps its own offsets, so one starting over refetches from zero. Best-effort: a
* failed poll answers `undefined` and mutates nothing, so the next resumes from the same
* offsets and a run always lands on `getJob` alone. */
export function createJobUpdateReader(jobId: string, workspace: string) {
let logs = ''
let resultStream = ''
let logOffset = 0
let streamOffset = 0
let started = false
return {
async poll(): Promise<{ completed: boolean; logs: string; resultStream: string } | undefined> {
let update: Awaited<ReturnType<typeof JobService.getJobUpdates>>
try {
update = await JobService.getJobUpdates({
workspace,
id: jobId,
running: started,
logOffset,
streamOffset
})
} catch {
return undefined
}
started ||= update.running ?? false
// Both kept as a tail: the offsets come from the server, so dropping the head
// costs nothing here, and neither is the record of the run — the logs are on the
// job, and a streamed partial is replaced by the result the moment it lands.
if (update.new_logs) logs = (logs + update.new_logs).slice(-MAX_LOG_LENGTH)
if (update.new_result_stream) {
resultStream = (resultStream + update.new_result_stream).slice(-MAX_LOG_LENGTH)
}
if (update.log_offset) logOffset = update.log_offset
if (update.stream_offset) streamOffset = update.stream_offset
return { completed: update.completed ?? false, logs, resultStream }
}
}
}
// Common job polling function.
//
// Two modes, selected by whether `detachAfterMs` is provided:
@@ -1563,24 +1699,54 @@ export async function pollJobCompletion(
const maxAttempts = detachEnabled ? Math.ceil((options?.detachAfterMs ?? 0) / 1000) : 60
let attempts = 0
let job: CompletedJob | null = null
const reader = createJobUpdateReader(jobId, workspace)
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000))
attempts++
try {
const update = await reader.poll()
// The tray's snapshot is trimmed of logs (it is persisted), so the card is the
// only place a running job's output can land. Cards that hide their logs while
// loading are unaffected; the run card follows them line by line.
if (update) {
toolCallbacks.setToolStatus(toolId, {
logs: formatLogs(update.logs),
resultStream: update.resultStream || undefined
})
}
// Ask for the logs when the run may be over — the tail written between the last
// poll and the end is only on the job itself — or when there is no reader output
// to have collected them.
const wantLogs = !update || update.completed
const fetchedJob = await JobService.getJob({
workspace: workspace,
id: jobId,
noLogs: false,
noLogs: !wantLogs,
noCode: true
})
if (fetchedJob.type === 'CompletedJob') {
job = fetchedJob
// The updates can still call a landed job unfinished, so a completion seen on
// a logless fetch is fetched again rather than settled without them: the model
// reads these logs, and their absence is indistinguishable from a silent run.
job = wantLogs
? fetchedJob
: ((await JobService.getJob({
workspace: workspace,
id: jobId,
noLogs: false,
noCode: true
})) as CompletedJob)
break
}
// Keep the tray's status + Job snapshot fresh during the inline wait.
// With no reader, this is the only place the card's logs can come from.
if (!update) {
toolCallbacks.setToolStatus(toolId, { logs: formatLogs(fetchedJob.logs) })
}
// The badge needs the real Job to tell running from suspended or scheduled, which
// the updates do not say.
toolCallbacks.onJobStatus?.(jobId, {
status: deriveChatJobStatus(fetchedJob),
job: trimJob(fetchedJob)
@@ -1694,14 +1860,22 @@ function backgroundedSummary(jobId: string, label: string): string {
// fills its card the same way one that finished inline does.
export function completedJobToolStatus(job: CompletedJob): Partial<ToolDisplayMessage> {
// A canceled job isn't a `success`, but it isn't a failure either — the user
// stopped it — so don't dress the card as an error.
// stopped it — so don't dress the card as an error. It still has the result the run
// page shows for a canceled run, which names who stopped it, so keep that.
if (job.canceled) {
return { content: 'Background job canceled', logs: formatLogs(job.logs) }
return {
content: 'Background job canceled',
result: formatResult(job.result),
logs: formatLogs(job.logs),
resultStream: undefined
}
}
return {
content: `Background job ${job.success ? 'completed successfully' : 'failed'}`,
result: formatResult(job.result),
logs: formatLogs(job.logs),
// The partial is the result now, so nothing streamed is kept beside it.
resultStream: undefined,
...(job.success ? {} : { error: getErrorMessage(job.result) })
}
}
@@ -1731,12 +1905,15 @@ export function backgroundJobCompletionNote(
)
}
// Main execution function for test runs
export async function executeTestRun(config: TestRunConfig): Promise<string> {
// Detach-into-background is enabled only when the host wired the job hooks
// (global/sessions chat). Otherwise this stays a blocking call.
const detachEnabled = !!config.toolCallbacks.onJobStarted
const label = config.label ?? config.contextName
const actionNoun = config.actionNoun ?? 'test'
// Stands on its own where the status strings are prefixed by the item, so its
// default carries the noun.
const failureNoun = config.actionNoun ?? 'test run'
try {
config.toolCallbacks.setToolStatus(config.toolId, {
content: config.startMessage || `Starting ${config.contextName} test...`
@@ -1761,7 +1938,8 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
})
config.toolCallbacks.setToolStatus(config.toolId, {
content: config.runningMessage ?? `${contextName} test started, waiting for completion...`
content:
config.runningMessage ?? `${contextName} ${actionNoun} started, waiting for completion...`
})
const outcome = await pollJobCompletion(
@@ -1781,7 +1959,7 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
if (outcome === 'detached') {
config.toolCallbacks.onJobDetached?.(jobId)
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${contextName} test running in background (job ${jobId})`
content: `${contextName} ${actionNoun} running in background (job ${jobId})`
})
return backgroundedSummary(jobId, label)
}
@@ -1800,9 +1978,12 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
}
config.toolCallbacks.setToolStatus(config.toolId, {
content: `${contextName} test ${job.success ? 'completed successfully' : 'failed'}`,
content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`,
result: formatResult(job.result),
logs: formatLogs(job.logs),
// The partial is the result now, so the card reads it off `result` alone and the
// transcript stops carrying a second copy of a streamed answer.
resultStream: undefined,
...(job.success ? {} : { error: getErrorMessage(job.result) })
})
@@ -1823,10 +2004,10 @@ export async function executeTestRun(config: TestRunConfig): Promise<string> {
// the flow it could not find — losing the one diagnostic the run exists for.
const errorMessage = formatToolError(error)
config.toolCallbacks.setToolStatus(config.toolId, {
content: `Test execution failed`,
content: `Execution failed`,
error: errorMessage
})
throw new Error(`Failed to execute test run: ${errorMessage}`)
throw new Error(`Failed to execute ${failureNoun}: ${errorMessage}`)
}
}
@@ -0,0 +1,276 @@
import { describe, expect, it } from 'vitest'
import {
coerceArgsToSchema,
enforceDisabledDefaults,
redactFileArgs,
redactSecretArgs
} from './job_args'
describe('coerceArgsToSchema', () => {
// A scalar widget renders its own reading of a wrong-typed value and never writes that
// reading back, so an untouched form submits something it never displayed: a number
// input paints `"12"` as a filled-looking 12, and a toggle shows `"false"` as on.
it('converts a value its widget would read, so the form shows what runs', () => {
const schema = {
properties: {
count: { type: 'number' },
flag: { type: 'boolean' },
label: { type: 'string' },
name: { type: 'string' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ count: '12', flag: 'false', label: 3, name: 'ada' },
schema
)
expect(args).toEqual({ count: 12, flag: false, label: '3', name: 'ada' })
expect(clearedKeys).toEqual([])
})
// Cleared, not carried: the widget shows nothing for these, so nothing is what an
// untouched form should send.
it('empties a value with no reading in its declared type', () => {
const schema = {
properties: {
count: { type: 'number' },
flag: { type: 'boolean' },
label: { type: 'string' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ count: 'abc', flag: 'maybe', label: { a: 1 } },
schema
)
expect(args).toEqual({})
expect(clearedKeys.sort()).toEqual(['count', 'flag', 'label'])
})
// A field the schema does not name is a field no run surface in the product draws, so a
// value under that name would reach the job without anyone having been able to see it.
// `constructor` is declared by every object through its prototype and by no schema.
it('drops arguments the schema does not declare, naming them', () => {
const kept = coerceArgsToSchema({ a: 'keep', b: 2, constructor: 'x' }, {
properties: { a: { type: 'string' } }
} as any)
expect(kept.args).toEqual({ a: 'keep' })
expect(kept.undeclaredKeys).toEqual(['b', 'constructor'])
// Declaring nothing is declaring no arguments, which is what a `**kwargs` script and a
// schema that failed to infer both look like.
expect(coerceArgsToSchema({ a: 1 }, undefined).args).toEqual({})
})
// Resolved by the job, so the declared type describes what it receives and never the
// string standing in for it. `Number('$var:…')` is NaN, so coercing would destroy it.
it('leaves a variable or resource reference in any slot', () => {
const schema = {
properties: {
size: { type: 'number' },
on: { type: 'boolean' },
db: { type: 'object', format: 'resource-postgresql' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ size: '$var:u/admin/size', on: '$var:u/admin/on', db: '$res:u/admin/pg' },
schema
)
expect(args).toEqual({
size: '$var:u/admin/size',
on: '$var:u/admin/on',
db: '$res:u/admin/pg'
})
expect(clearedKeys).toEqual([])
})
// Not merely unreadable: `MultiSelect` maps over the value as it renders, so anything
// else throws and takes the whole card down, Cancel with it. A reference is no
// exception — the widget draws before anything resolves — so this slot is the one
// place the reference rule above does not hold.
it('empties a non-array in a dyn-multiselect slot, reference included', () => {
const schema = { properties: { tags: { type: 'object', format: 'dynmultiselect-list' } } }
expect(coerceArgsToSchema({ tags: ['a'] }, schema).args).toEqual({ tags: ['a'] })
for (const bad of [{ a: 1 }, '$var:u/admin/watchlist']) {
const { args, clearedKeys } = coerceArgsToSchema({ tags: bad }, schema)
expect(args).toEqual({})
expect(clearedKeys).toEqual(['tags'])
}
})
// Below the top the form has the same limitations as everywhere else in the product,
// and descending means resolving `oneOf` branches — where being wrong rewrites what the
// user typed into the branch they did open.
it('leaves nested and container values to the widget that renders them', () => {
const schema = {
properties: {
obj: { type: 'object', properties: { known: { type: 'string' } } },
rows: { type: 'array', items: { type: 'object' } }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ obj: { known: 1, extra: 'b' }, rows: { id: 'x' } },
schema
)
expect(args).toEqual({ obj: { known: 1, extra: 'b' }, rows: { id: 'x' } })
expect(clearedKeys).toEqual([])
})
// Both sides parsed, never written as literals: `__proto__:` in an object literal is
// the prototype setter, so a literal declares nothing to coerce in the first place.
it('keeps a declared __proto__ instead of losing it to the setter', () => {
const { args } = coerceArgsToSchema(
JSON.parse('{"__proto__":"legit","keep":1}'),
JSON.parse('{"properties":{"__proto__":{"type":"string"},"keep":{"type":"number"}}}')
)
expect(Object.hasOwn(args, '__proto__')).toBe(true)
expect(args['__proto__']).toBe('legit')
})
})
describe('enforceDisabledDefaults', () => {
const schema = {
properties: {
locked: { type: 'string', disabled: true, default: 'fixed' },
open: { type: 'string' }
}
}
it('overwrites a disabled field and reports only what it changed', () => {
expect(enforceDisabledDefaults({ locked: 'mine', open: 'ok' }, schema)).toEqual({
args: { locked: 'fixed', open: 'ok' },
resetKeys: ['locked']
})
// Never supplied is not overwritten: the field shows the default either way, and a
// caller told otherwise would try to correct what it never sent.
expect(enforceDisabledDefaults({ open: 'ok' }, schema)).toEqual({
args: { locked: 'fixed', open: 'ok' },
resetKeys: []
})
})
it('reports no reset for an object default the caller already matched', () => {
const objSchema = {
properties: { conf: { type: 'object', disabled: true, default: { a: 1 } } }
}
expect(enforceDisabledDefaults({ conf: { a: 1 } }, objSchema).resetKeys).toEqual([])
})
})
describe('secret args at every level the form nests', () => {
const schema = {
properties: {
top: { type: 'string', password: true },
obj: { properties: { inner: { type: 'string', password: true } } },
list: { items: { properties: { secret: { type: 'string', password: true } } } },
either: {
oneOf: [
{ title: 'a', properties: { key: { type: 'string', password: true } } },
{ title: 'b', properties: { other: { type: 'string', password: true } } }
]
}
}
}
const args = {
top: 'hunter2',
obj: { inner: '$var:u/ada/prod', keep: 1 },
list: [{ secret: 'one', name: 'a' }, { secret: 'two' }],
// Tagged as branch 'a', but 'b' is stripped too: the tag is runtime state.
either: { kind: 'a', key: 'k', other: 'o' }
}
it('redacts every value and keeps every reference', () => {
const redacted = JSON.stringify(redactSecretArgs(args, schema))
for (const secret of ['hunter2', 'one', 'two', '"k"', '"o"']) {
expect(redacted).not.toContain(secret)
}
expect(redacted).toContain('<hidden>')
expect(redacted).toContain('"name":"a"')
expect(redacted).toContain('$var:u/ada/prod')
})
// ArgInput synthesises '' for every untouched string, so marking one would put a hidden
// value on the card for a field nobody filled in — and mint nothing to back it.
it('leaves an empty secret empty', () => {
expect(
redactSecretArgs({ tok: '' }, { properties: { tok: { type: 'string', password: true } } })
).toEqual({ tok: '' })
})
it('reaches a secret under a oneOf branch of an array element', () => {
const oneOfItems = {
properties: {
steps: {
type: 'array',
items: {
oneOf: [{ title: 'push', properties: { token: { type: 'string', password: true } } }]
}
}
}
}
expect(redactSecretArgs({ steps: [{ token: 'hunter2', name: 'a' }] }, oneOfItems)).toEqual({
steps: [{ token: '<hidden>', name: 'a' }]
})
})
// The walk descends on the value's shape: routing an array down `properties` because the
// declaration carries that key would visit none of its elements, leaving the secret in
// the persisted card verbatim.
it('reaches through a declaration carrying both items and properties', () => {
const both = {
properties: {
creds: {
type: 'array',
items: { properties: { token: { type: 'string', password: true } } },
properties: { token: { type: 'string', password: true } }
}
}
}
expect(redactSecretArgs({ creds: [{ token: 'hunter2' }] }, both)).toEqual({
creds: [{ token: '<hidden>' }]
})
})
// A container shaped unlike its declaration is kept, so the walk has to reach in through
// the half the declaration does carry — on the value's shape alone it stops at the
// mismatch, leaving the secret there for the persisted card and the model to read.
it('reaches through a container shaped unlike its declaration', () => {
const declaresArray = {
properties: {
rows: { type: 'array', items: { properties: { token: { password: true } } } }
}
}
expect(redactSecretArgs({ rows: { token: 'hunter2' } }, declaresArray)).toEqual({
rows: { token: '<hidden>' }
})
const declaresObject = {
properties: { cfg: { type: 'object', properties: { token: { password: true } } } }
}
expect(redactSecretArgs({ cfg: [{ token: 'hunter2' }] }, declaresObject)).toEqual({
cfg: [{ token: '<hidden>' }]
})
})
})
describe('redactFileArgs', () => {
const schema = {
properties: {
doc: { type: 'string', contentEncoding: 'base64' },
pics: { type: 'array', items: { type: 'string', contentEncoding: 'base64' } },
wrap: { properties: { inner: { type: 'string', contentEncoding: 'base64' } } },
note: { type: 'string' }
}
}
it('replaces the bytes with a size marker at every level, and keeps the rest', () => {
const oneMeg = 'A'.repeat(1024 * 1024 * 2)
const redacted = redactFileArgs(
{ doc: oneMeg, pics: ['B'.repeat(4096)], wrap: { inner: 'C'.repeat(2048) }, note: 'hi' },
schema
)
expect(redacted).toEqual({
doc: '<file: 1.5 MB>',
pics: ['<file: 3 KB>'],
wrap: { inner: '<file: 2 KB>' },
note: 'hi'
})
})
})
+298
View File
@@ -1,5 +1,303 @@
/**
* A job's arguments prepared for a run form, its readers, and a result view. Coercing must
* not lose what the caller meant to send, so it is exact and shallow; stripping and
* redacting only blank a field, so they go to any depth and err towards visiting too much.
*/
import { deepEqual } from 'fast-equals'
const isLockedProp = (prop: any) => !!prop?.disabled && 'default' in prop
/**
* A field the schema disables is not the caller's to set: the run sends the schema's
* default whatever it holds. Top-level only, like every filter here. Returns the keys it
* overwrote; notifying is the caller's job.
*/
export function enforceDisabledDefaults(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): { args: Record<string, any>; resetKeys: string[] } {
// Null prototype: assigning a declared `__proto__` into a plain `{}` reaches the
// inherited setter and the default vanishes. Always copied — callers bind the result to
// a form that edits in place, so returning the input would write through to theirs.
const result: Record<string, any> = Object.assign(Object.create(null), args)
if (!schema?.properties) return { args: { ...result }, resetKeys: [] }
const resetKeys: string[] = []
for (const [key, prop] of Object.entries<any>(schema.properties)) {
if (!isLockedProp(prop)) continue
// Never supplied is not overwritten, and compared by value: a default can be an
// object, where identity would report every correct run as overridden.
if (result[key] !== undefined && !deepEqual(result[key], prop.default)) resetKeys.push(key)
result[key] = prop.default
}
return { args: { ...result }, resetKeys }
}
/** How a form says what {@link enforceDisabledDefaults} overwrote, shared by the two that
* run it so the wording cannot drift apart. */
export const resetKeysToast = (resetKeys: string[]): string =>
`Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys
.map((k) => `'${k}'`)
.join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}`
/** Types `setInputCat` routes to a widget bound to a scalar. */
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean'])
/**
* Declares an array though its `type` says `object`. A mismatch here throws rather than
* reading wrong: `MultiSelect` maps over the value as it renders, so anything else takes
* the form down, Cancel with it a reference included, since it draws before resolving.
*/
const declaresDynMultiselect = (prop: any) =>
typeof prop?.format === 'string' && prop.format.startsWith('dynmultiselect-')
const fitsScalarType = (value: any, type: string): boolean =>
type === 'integer' ? typeof value === 'number' : typeof value === type
/**
* Resolved at run time, so the declared type describes what the job receives and never the
* string standing in for it. `ArgInput.validateInput` blesses these ahead of every type check.
*/
const REFERENCE_PREFIXES = ['$var:', '$res:', '$jsonvar:']
const isReference = (value: any): boolean =>
typeof value === 'string' && REFERENCE_PREFIXES.some((prefix) => value.startsWith(prefix))
/** No plain reading in the declared type; distinct from a value that reads as `undefined`. */
const UNCOERCIBLE = Symbol('uncoercible')
/**
* The value a scalar widget would stand for, or {@link UNCOERCIBLE}. Only conversions with
* one plain reading: a number input shows `"7"` as 7 and a toggle shows any non-empty
* string as on, so guessing past this would put a value on screen that nobody wrote.
*/
function coerceScalar(value: any, type: string): any {
if (typeof value === 'object') return UNCOERCIBLE
if (type === 'string') {
return typeof value === 'number' || typeof value === 'boolean' ? String(value) : UNCOERCIBLE
}
if (typeof value !== 'string') return UNCOERCIBLE
const trimmed = value.trim()
if (type === 'number' || type === 'integer') {
if (trimmed === '') return UNCOERCIBLE
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : UNCOERCIBLE
}
if (type === 'boolean') {
if (trimmed.toLowerCase() === 'true') return true
if (trimmed.toLowerCase() === 'false') return false
}
return UNCOERCIBLE
}
/**
* Drop every argument the schema does not declare, naming them. The schema is what every run
* surface builds its fields from, so a value under a name it never declares has no widget
* anywhere: sending one is sending what nobody could see or edit before the run.
*/
export function dropUndeclaredArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): { args: Record<string, any>; undeclaredKeys: string[] } {
const properties = schema?.properties ?? {}
// hasOwn, not `in`: every object inherits `constructor` and `toString`, so `in` would
// hand an inherited declaration to an argument the schema never named.
const kept: Record<string, any> = Object.create(null)
const undeclaredKeys: string[] = []
for (const [key, value] of Object.entries(args ?? {})) {
if (Object.hasOwn(properties, key)) kept[key] = value
else undeclaredKeys.push(key)
}
return { args: { ...kept }, undeclaredKeys }
}
/**
* Make arguments say what the run form will show, then apply {@link enforceDisabledDefaults}.
* A scalar widget renders its own reading of a wrong-typed value and never writes it back, so
* an untouched form would submit what it never displayed; a value with no reading is cleared.
* Top-level only: descending means resolving `oneOf`, where being wrong rewrites user input.
*/
export function coerceArgsToSchema(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): {
args: Record<string, any>
resetKeys: string[]
clearedKeys: string[]
undeclaredKeys: string[]
} {
const properties = schema?.properties ?? {}
const clearedKeys: string[] = []
const { args: declared, undeclaredKeys } = dropUndeclaredArgs(args, schema)
const kept: Record<string, any> = Object.create(null)
for (const [key, value] of Object.entries(declared)) {
// Declared, but a declaration can still be nothing, and reading `.type` off it throws.
const prop = properties[key]
if (
prop === undefined ||
value == null ||
(isReference(value) && !declaresDynMultiselect(prop))
) {
kept[key] = value
continue
}
if (declaresDynMultiselect(prop)) {
if (Array.isArray(value)) kept[key] = value
else clearedKeys.push(key)
continue
}
if (!SCALAR_TYPES.has(prop.type) || fitsScalarType(value, prop.type)) {
kept[key] = value
continue
}
const coerced = coerceScalar(value, prop.type)
if (coerced === UNCOERCIBLE) clearedKeys.push(key)
else kept[key] = coerced
}
const { args: result, resetKeys } = enforceDisabledDefaults({ ...kept }, schema)
return { args: result, resetKeys, clearedKeys, undeclaredKeys }
}
/**
* Every bag of `properties` a declaration can show a value's keys through, including every
* `oneOf` branch rather than the selected one: a secret under a variant nobody opened
* leaves the form just the same.
*/
function declarationBags(prop: any): Record<string, any>[] {
const bags: Record<string, any>[] = []
if (prop?.properties) bags.push(prop.properties)
if (Array.isArray(prop?.oneOf))
for (const branch of prop.oneOf) if (branch?.properties) bags.push(branch.properties)
return bags
}
/**
* Apply `visit` to every value whose declaration matches `isLeaf`, at any depth; returning
* `undefined` removes it. Recursive because the form is, so a level left unvisited is one a
* secret can sit at. Descends on the value's shape, never on the declaration's keys: one
* carrying both `items` and `properties` must not route a shape down the other's branch.
*/
function mapLeaves(
value: any,
prop: any,
isLeaf: (prop: any) => boolean,
visit: (value: unknown, prop: any, path: (string | number)[]) => unknown,
path: (string | number)[]
): any {
if (value == null || typeof value !== 'object') return value
// A container shaped unlike its declaration is kept rather than dropped, since the widget
// is the one that reports it — so the walk has to reach in through whichever half the
// declaration does carry, or a secret under one leaves the form verbatim.
if (Array.isArray(value))
return value.map((item, i) => mapLeaves(item, prop?.items ?? prop, isLeaf, visit, [...path, i]))
const bags = declarationBags(prop)
if (bags.length === 0)
return prop?.items ? mapLeaves(value, prop.items, isLeaf, visit, path) : value
// Null prototype, and keyed off the value rather than the declaration: a key is only
// ever rewritten where it already exists, so no branch of a `oneOf` can add one.
const result: Record<string, any> = Object.assign(Object.create(null), value)
for (const key of Object.keys(result)) {
const declared = bags.filter((bag) => Object.hasOwn(bag, key)).map((bag) => bag[key])
// Segments, never a joined name: a key can itself hold a dot, and two leaves reported
// under one name let a caller correlating by it take the one for the other.
const keyPath = [...path, key]
// A matching object is a leaf, not a level: a password object is stored whole as a
// single $jsonvar: reference, and a file is one opaque base64 string.
const leaf = declared.find(isLeaf)
if (leaf) {
const mapped = visit(result[key], leaf, keyPath)
if (mapped === undefined) delete result[key]
else result[key] = mapped
continue
}
for (const declaration of declared)
result[key] = mapLeaves(result[key], declaration, isLeaf, visit, keyPath)
}
return { ...result }
}
export const isSecretProp = (prop: any) => !!prop?.password
const isFileProp = (prop: any) =>
prop?.contentEncoding === 'base64' || prop?.items?.contentEncoding === 'base64'
function fileMarker(base64: string): string {
const bytes = Math.floor((base64.length * 3) / 4)
return bytes < 1024 * 1024
? `<file: ${Math.max(1, Math.round(bytes / 1024))} KB>`
: `<file: ${(bytes / 1024 / 1024).toFixed(1)} MB>`
}
/** {@link mapLeaves} over a whole argument object, against a schema that may declare
* nothing to match. Copies either way, for the reason {@link enforceDisabledDefaults}
* copies. */
export function mapArgLeaves(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined,
isLeaf: (prop: any) => boolean,
visit: (value: unknown, prop: any, path: (string | number)[]) => unknown
): Record<string, any> {
return mapLeaves(args ?? {}, { properties: schema?.properties ?? {} }, isLeaf, visit, [])
}
/** A leaf's path as the lines naming it to a reader read: `creds[0].secret`. */
const formatArgPath = (path: (string | number)[]): string =>
path.reduce<string>(
(acc, segment) =>
typeof segment === 'number'
? `${acc}[${segment}]`
: acc
? `${acc}.${segment}`
: String(segment),
''
)
/**
* Drop every file argument, so a caller cannot propose file bytes on the user's behalf:
* the field opens empty and the user attaches the file. Bytes a form is prefilled with
* are bytes the stored transcript carries, unbounded, for a value no caller can produce.
* Reports the path of each one removed, or the caller reads the absence as the user having
* deleted the value.
*/
export function stripFileArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined,
strippedKeys?: string[]
): Record<string, any> {
return mapArgLeaves(args, schema, isFileProp, (value, _prop, path) => {
if (value !== undefined) strippedKeys?.push(formatArgPath(path))
return undefined
})
}
/**
* Replace a sensitive value with a fixed marker, for text that leaves the form. A reference is
* kept: it names a variable rather than holding one, and the run page shows the same job's
* arguments that way. An empty field is kept for the reason `processSecretArgs` mints nothing
* for one marking it would describe a secret the run never carried.
*/
export function redactSecretArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): Record<string, any> {
return mapArgLeaves(args, schema, isSecretProp, (value) =>
value == null ? undefined : value === '' || isReference(value) ? value : '<hidden>'
)
}
/**
* Replace every file argument with a marker naming its size. The base64 belongs in the
* job request and nowhere else: rendered it is unreadable, persisted it is unbounded, and
* a file small enough to survive truncation reaches the model whole.
*/
export function redactFileArgs(
args: Record<string, any>,
schema: { properties?: Record<string, any> } | undefined
): Record<string, any> {
const mark = (value: unknown) => (typeof value === 'string' ? fileMarker(value) : value)
return mapArgLeaves(args, schema, isFileProp, (value) =>
Array.isArray(value) ? value.map(mark) : mark(value)
)
}
export function isWindmillTooBigObject(obj: any): boolean {
return (
typeof obj === 'object' &&
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const created: { path: string; value: string; is_secret?: boolean }[] = []
vi.mock('$lib/gen', () => ({
VariableService: {
createVariable: vi.fn(async ({ requestBody }: any) => {
created.push(requestBody)
return requestBody.path
})
}
}))
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { workspaceStore: writable('test-ws'), userStore: writable({ username: 'ada' }) }
})
import { processSecretArgs } from './secretArgUtils'
describe('processSecretArgs', () => {
beforeEach(() => (created.length = 0))
const schema = {
properties: {
token: { type: 'string', password: true },
creds: { type: 'object', password: true, properties: { user: { type: 'string' } } },
nested: { type: 'object', properties: { inner: { type: 'string', password: true } } },
plain: { type: 'string' }
}
} as any
// Nothing else turns a proposed secret into a reference when no form mounts, so a literal
// left alone here is a plaintext credential stored on the job for anyone who can see it.
it('mints a reference for a literal at every level, leaving other arguments alone', async () => {
const out = await processSecretArgs(
{ token: 'hunter2', creds: { user: 'ada' }, nested: { inner: 'deep' }, plain: 'kept' },
schema
)
expect(out.token).toMatch(/^\$var:u\/ada\/secret_arg\//)
expect(out.creds).toMatch(/^\$jsonvar:u\/ada\/secret_arg\//)
expect(out.nested.inner).toMatch(/^\$var:u\/ada\/secret_arg\//)
expect(out.plain).toBe('kept')
// The object goes into the variable as JSON, which is what `$jsonvar:` parses back.
expect(created.map((c) => c.value).sort()).toEqual(['deep', 'hunter2', '{"user":"ada"}'])
expect(created.every((c) => c.is_secret)).toBe(true)
})
it('leaves a reference the caller already named alone', async () => {
const out = await processSecretArgs(
{ token: '$var:f/team/api_token', creds: '$jsonvar:u/ada/existing' },
schema
)
expect(out).toEqual({ token: '$var:f/team/api_token', creds: '$jsonvar:u/ada/existing' })
expect(created).toEqual([])
})
// `$var:` hands the job the variable's text; a field declaring an object needs it parsed,
// which is the same variable read the other way rather than a secret the caller cannot see.
it('reads a plain variable as JSON where the field cannot hold a string', async () => {
const out = await processSecretArgs({ creds: '$var:u/ada/stripe' }, schema)
expect(out.creds).toBe('$jsonvar:u/ada/stripe')
expect(created).toEqual([])
})
// Reached by every run form in the product, not only the ones a chat opens.
it('leaves an absent, null or empty secret alone', async () => {
expect(await processSecretArgs({ token: null, plain: 'kept' }, schema)).toEqual({
token: null,
plain: 'kept'
})
expect(await processSecretArgs({ token: '', plain: 'kept' }, schema)).toEqual({
token: '',
plain: 'kept'
})
expect(created).toEqual([])
})
// A property name can itself contain a dot. Reported under one label these two leaves
// would share a mint, and the flat field would run on the nested field's secret.
it("tells apart a key that spells another key's path", async () => {
const out = await processSecretArgs({ 'db.password': 'FLAT', db: { password: 'NESTED' } }, {
properties: {
'db.password': { type: 'string', password: true },
db: {
type: 'object',
properties: { password: { type: 'string', password: true } }
}
}
} as any)
const flat = out['db.password'].slice('$var:'.length)
const nested = out.db.password.slice('$var:'.length)
expect(flat).not.toBe(nested)
expect(created.find((c) => c.path === flat)?.value).toBe('FLAT')
expect(created.find((c) => c.path === nested)?.value).toBe('NESTED')
})
// Callers bind a form that stays editable while the mints are in flight, and a leaf is
// addressed by its path: a row moved between the two walks would take the other row's
// reference and the job would run it on the wrong credentials.
it('ignores the caller mutating the arguments while minting', async () => {
const rows = {
creds: [
{ name: 'alpha', secret: 'FIRST' },
{ name: 'beta', secret: 'SECOND' }
]
}
const arraySchema = {
properties: {
creds: {
type: 'array',
items: { properties: { name: {}, secret: { password: true } } }
}
}
} as any
const pending = processSecretArgs(rows, arraySchema)
rows.creds.reverse()
const out = await pending
// Keyed by the row's own name, not its index: a substitution by position lands the
// first-minted reference on index 0 either way.
const secretOf = (name: string) => {
const row = out.creds.find((c: any) => c.name === name)
return created.find((c) => c.path === row.secret.slice('$var:'.length))?.value
}
expect(secretOf('alpha')).toBe('FIRST')
expect(secretOf('beta')).toBe('SECOND')
})
})
+89 -22
View File
@@ -3,11 +3,54 @@ import { VariableService } from '$lib/gen'
import { get } from 'svelte/store'
import { userStore, workspaceStore } from '$lib/stores'
import { generateRandomString } from '$lib/utils'
import { stateSnapshot } from '$lib/stateSnapshot.svelte'
import { isSecretProp, mapArgLeaves } from './job_args'
/** Where a caller's own ephemeral secrets live, so a field can tell one it minted from a
* workspace variable someone linked by hand. */
export function ephemeralSecretPrefix(username: string): string {
return `u/${username}/secret_arg/`
}
/**
* Process args before job submission: for non-string fields marked as password/sensitive,
* create ephemeral secret variables and replace values with $jsonvar:path references.
* String password fields are already handled by PasswordArgInput (uses $var:).
* Mint the ephemeral secret variable a sensitive argument is submitted as, and return its path.
* It expires on its own, so a run that is abandoned leaves no permanent secret behind.
*/
export async function mintEphemeralSecret(
workspace: string,
username: string,
value: string
): Promise<string> {
const path = ephemeralSecretPrefix(username) + generateRandomString(12)
await VariableService.createVariable({
workspace,
requestBody: {
value,
is_secret: true,
path,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
return path
}
/** `$var:` hands the job the variable's text and `$jsonvar:` hands it the parsed value, so a
* field that cannot hold a string needs the second one whichever the caller named. */
function referencePrefix(prop: any, value: unknown): '$var:' | '$jsonvar:' {
return typeof value === 'string' && prop?.type !== 'object' && prop?.type !== 'array'
? '$var:'
: '$jsonvar:'
}
/**
* Turn every sensitive argument into a reference before the job is submitted: a plaintext value
* is minted into an ephemeral secret variable, so what is stored on the job and readable by
* anyone who can see its run names a secret instead of holding one.
*
* The single place that decides how a secret reaches a job: {@link PasswordArgInput} mints
* through it while the user types, and a run the autonomy posture starts without a form calls it
* in the widget's stead.
*/
export async function processSecretArgs(
args: Record<string, any>,
@@ -24,29 +67,53 @@ export async function processSecretArgs(
const username = (user.username ?? user.email)?.split('@')[0]
if (!username) return args
const userPrefix = `u/${username}/secret_arg/`
const result = { ...args }
// Detached from the caller: every one binds a form that stays editable across the awaits
// below, and the two walks address a leaf by its path — an array reordered between them
// would hand a row the reference minted for another row's secret.
args = stateSnapshot(args)
for (const [key, prop] of Object.entries(schema.properties)) {
if (!prop.password) continue
if (prop.type !== 'object') continue // only object types; strings handled by PasswordArgInput
if (result[key] == null || result[key] === undefined) continue
if (typeof result[key] === 'string' && result[key].startsWith('$jsonvar:')) continue // already processed
// A value that already names a variable is one; anything else is the secret itself. An empty
// field holds nothing to mint, and ArgInput synthesises '' for every untouched string.
const holdsSecret = (value: unknown) =>
value != null &&
value !== '' &&
!(
typeof value === 'string' &&
(value.startsWith('$var:') || value.startsWith('$jsonvar:') || value.startsWith('$res:'))
)
const path = userPrefix + generateRandomString(12)
await VariableService.createVariable({
// Collected first and substituted after, because the walk is synchronous and minting is not.
// Keyed by the whole path the walk reports, which is what tells two same-named leaves apart.
const pending: { key: string; prop: any; value: unknown }[] = []
mapArgLeaves(args, schema as any, isSecretProp, (value, prop, path) => {
if (holdsSecret(value)) pending.push({ key: JSON.stringify(path), prop, value })
return value
})
const minted = new Map<string, string>()
for (const { key, prop, value } of pending) {
const reference = referencePrefix(prop, value)
const variable = await mintEphemeralSecret(
workspace,
requestBody: {
value: JSON.stringify(result[key]),
is_secret: true,
path,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
result[key] = '$jsonvar:' + path
username,
reference === '$var:' ? String(value) : JSON.stringify(value)
)
minted.set(key, reference + variable)
}
return result
return mapArgLeaves(args, schema as any, isSecretProp, (value, prop, path) => {
const replacement = minted.get(JSON.stringify(path))
if (replacement !== undefined) return replacement
// A plain variable named for a field that cannot hold a string: the caller meant that
// variable's contents, which is the same secret read the way the field needs it.
if (
typeof value === 'string' &&
value.startsWith('$var:') &&
referencePrefix(prop, value) === '$jsonvar:'
) {
return '$jsonvar:' + value.slice('$var:'.length)
}
return value
})
}
@@ -19,6 +19,7 @@
} from './previewRouter'
import { withMenuHidden } from './sessionMode.svelte'
import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte'
import RunFormPreviewSlot from './RunFormPreviewSlot.svelte'
import { setOverlayHost } from '../common/overlayHost.svelte'
let {
@@ -327,6 +328,20 @@
<div class="p-4 text-sm text-tertiary">This artifact is no longer available.</div>
{/if}
</div>
{:else if slot.kind === 'runform' && mounted}
<div
bind:this={overlayHostEl}
class="absolute inset-0 flex flex-col min-h-0 bg-surface {visibility}"
aria-hidden={!active}
>
<!-- Waits for the host element itself: Drawer portals when it mounts, and the portal
action reads its target once, so a form mounted in the same pass as this div would
resolve no host and open against the viewport. The branches above are async
(a dynamic import, a loaded artifact), which is what spares them this. -->
{#if runtime && overlayHostEl}
<RunFormPreviewSlot manager={runtime.manager} toolCallId={slot.toolCallId} />
{/if}
</div>
{:else if mounted}
<iframe
bind:this={frame}
@@ -0,0 +1,60 @@
<script lang="ts">
import { setContext, untrack } from 'svelte'
import { Code } from 'lucide-svelte'
import RunArgsFormDisplay from '$lib/components/copilot/chat/RunArgsFormDisplay.svelte'
import { isActiveRunForm, type ToolDisplayMessage } from '$lib/components/copilot/chat/shared'
import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
interface Props {
manager: AIChatManager
toolCallId: string
}
let { manager, toolCallId }: Props = $props()
// The panel is not under the chat's context (SessionEditorTarget sets it for the same
// reason), and RunArgsFormDisplay resolves its manager from it. Without this the form
// binds to the app-wide singleton: a different draft, and a plan-mode flag that is not
// this session's — which renders every field disabled.
// Captured at init, as SessionEditorTarget does: a reused instance keeps the first
// runtime's manager, and descendants rely on the context's presence, not its identity.
setContext(
'aiChatManager',
untrack(() => manager)
)
// The card's own message, so the pane renders the same form the chat holds rather than a
// second one: RunArgsFormDisplay resolves the loop's pending callback through the manager,
// which is why pressing Run here is the chat pressing Run.
const message = $derived(
manager.displayMessages.find(
(m): m is ToolDisplayMessage =>
m.role === 'tool' && m.tool_call_id === toolCallId && !!m.runForm
)
)
// A tab outlives a chat rotation, and a settled form has nothing left to fill in.
const pending = $derived(message ? isActiveRunForm(message) : false)
</script>
<!-- surface-tertiary because the form's scroll fades gradient from it: the card is that
colour, and a different ground here would leave a visible band at each fade. -->
<div class="flex h-full min-h-0 flex-col bg-surface-tertiary">
{#if message?.runForm && pending}
<!-- No rule under it, as ArtifactViewer's header has none: the fields scroll under a
fade, and a border would draw that same boundary a second time. -->
<div class="flex items-center gap-2 p-3">
<Code class="h-4 w-4 shrink-0 text-accent" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-emphasis">
Run {message.runForm.summary || message.runForm.path}
</p>
{#if message.runForm.summary}
<p class="truncate font-mono text-2xs text-secondary">{message.runForm.path}</p>
{/if}
</div>
</div>
<RunArgsFormDisplay {toolCallId} runForm={message.runForm} layout="pane" />
{:else}
<div class="p-4 text-sm text-tertiary">This run form is no longer available.</div>
{/if}
</div>
@@ -10,9 +10,11 @@ import {
parseArtifactRoute,
parsePreviewItemRoute,
parsePreviewSelectedId,
parseRunFormRoute,
previewLocationContext,
previewLocationLabel,
resolvePreviewTab
resolvePreviewTab,
runFormUrl
} from './previewRouter'
describe('drawerAnchorFor', () => {
@@ -391,3 +393,26 @@ describe('artifact route', () => {
expect(previewLocationLabel('artifact:abc')).toBe('Artifact')
})
})
describe('run form route', () => {
it('round-trips the tool call and its label, including special chars', () => {
// This url is persisted with the tab, so one that does not read back comes back as an
// unopenable tab on every reload rather than failing once.
for (const [toolCallId, label] of [
['call_abc', 'Run refund'],
['call#with/odd:chars', 'weird # % / summary'],
['call_x', '']
] as const) {
expect(parseRunFormRoute(runFormUrl(toolCallId, label))).toEqual({ toolCallId, label })
}
})
it('resolves to a mounted form rather than a frame, and never claims another url', () => {
expect(resolvePreviewTab(runFormUrl('call_abc', 'Run refund'))).toEqual({
kind: 'runform',
toolCallId: 'call_abc'
})
expect(parseRunFormRoute('/run/01a0')).toBeNull()
expect(parseRunFormRoute('artifact:abc#Plan')).toBeNull()
})
})
@@ -67,6 +67,7 @@ export type PreviewTarget =
| { type: 'page'; href: string; label: string }
| { type: 'item'; item: WorkspaceItem }
| { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget }
| { type: 'runform'; toolCallId: string; label: string }
export type PreviewPage = { label: string; path: string; icon: DrillIcon }
@@ -123,9 +124,9 @@ const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const
/** Drop the params the preview host injects, so a location observed in the frame can be
* compared with the one that was commanded (which never carries them). */
export function canonicalizeObservedLoc(loc: string): string {
// An artifact is a scheme, not a path — `new URL` would happily parse it and hand
// back a pathname with the scheme gone.
if (parseArtifactRoute(loc)) return loc
// An artifact or a run form is a scheme, not a path — `new URL` would happily parse it
// and hand back a pathname with the scheme gone.
if (parseArtifactRoute(loc) || parseRunFormRoute(loc)) return loc
try {
const u = new URL(loc, 'http://_')
for (const p of INJECTED_PARAMS) u.searchParams.delete(p)
@@ -196,6 +197,10 @@ export type PreviewLocation = {
export function describeLocation(loc: string): PreviewLocation {
const artifact = parseArtifactRoute(loc)
if (artifact) return { identity: `artifact:${artifact.id}`, view: '', anchor: '' }
const runForm = parseRunFormRoute(loc)
// Identity is the call, never the label: that carries the script's summary, so folding it
// in would open a second tab for the same form whenever the summary differed.
if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' }
const canonical = canonicalizeObservedLoc(loc)
const path = stripBase(canonical)
const bare = canonical.split('#')[0]
@@ -363,6 +368,8 @@ export function matchReusablePage(href: string): PreviewPage | undefined {
export function previewLocationLabel(url: string): string {
const artifact = parseArtifactRoute(url)
if (artifact) return artifact.name || 'Artifact'
const runForm = parseRunFormRoute(url)
if (runForm) return runForm.label || 'Run form'
const page = matchReusablePage(url)
if (page) return page.label
const trigger = triggerLabelForPath(url)
@@ -443,6 +450,22 @@ export function parseArtifactRoute(
}
}
// A chat run form, addressed by the tool call it belongs to. A scheme rather than a path
// for the same reason as artifacts: this tab mounts a component, so there is no page for a
// frame to load, and the label rides in the hash so the strip names it without a lookup.
export function parseRunFormRoute(url: string): { toolCallId: string; label: string } | null {
const m = url.match(/^runform:([^?#]+)(?:#(.*))?$/)
if (!m) return null
return {
toolCallId: decodeURIComponent(m[1]),
label: m[2] ? decodeURIComponent(m[2]) : ''
}
}
export function runFormUrl(toolCallId: string, label: string): string {
return `runform:${encodeURIComponent(toolCallId)}#${encodeURIComponent(label)}`
}
export function artifactUrl(id: string, name: string, version?: number): string {
// Only stamp a version parseArtifactRoute can read back: this url is persisted with the
// tab, so one that round-trips to null would come back as an unopenable tab every reload.
@@ -468,11 +491,14 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
export type PreviewSlot =
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
| { kind: 'artifact'; id: string; version?: number }
| { kind: 'runform'; toolCallId: string }
| { kind: 'iframe' }
export function resolvePreviewTab(url: string): PreviewSlot {
const artifact = parseArtifactRoute(url)
if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version }
const runForm = parseRunFormRoute(url)
if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId }
const pipelineFolder = parsePipelineRoute(url)
if (pipelineFolder) {
return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder }
@@ -13,8 +13,10 @@ import {
previewLocationContext,
promptSafe,
parsePreviewItemRoute,
parseRunFormRoute,
previewLocationLabel,
resolvePreviewTab,
runFormUrl,
stripBase,
type ArtifactVersionTarget,
type PreviewTarget
@@ -80,6 +82,7 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string {
if (target.type === 'artifact') {
return artifactUrl(target.id, target.name, keptVersion(target, onto))
}
if (target.type === 'runform') return runFormUrl(target.toolCallId, target.label)
return `${base}${editPathFor(target.item)}`
}
@@ -576,6 +579,26 @@ export class SessionPreviewTabs {
if (tab) this.close(tab.id)
}
/** The tab holding a tool call's run form, for the runtime to retarget once the run
* has a job, or to close when the call is settled without one. */
runFormTabId(toolCallId: string): string | undefined {
return this.#tabs.find((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId)?.id
}
closeRunForm(toolCallId: string): void {
const id = this.runFormTabId(toolCallId)
if (id) this.close(id)
}
/** Point the tab holding a run form at something else, in place. Close-then-open would
* send the tab to the end of the strip, which is not what following a call looks like. */
retargetRunForm(toolCallId: string, url: string): void {
const tab = this.#tabs.find((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId)
if (!tab) return
retargetTab(tab, url)
this.#flush()
}
setCollapsed(collapsed: boolean): void {
if (this.#collapsed === collapsed) return
this.#collapsed = collapsed
@@ -436,6 +436,22 @@ describe('SessionPreviewTabs.open', () => {
expect(o.activeId).toBe(id)
})
it('dedupes a run form by tool call, and hands that tab to the run in place', () => {
const o = owner()
o.open({ type: 'page', href: `${base}/runs`, label: 'Runs' })
o.open({ type: 'runform', toolCallId: 'call_1', label: 'Run refund' })
const id = o.tabs[1].id
// The label carries the script's summary, which is not what identifies the call.
expect(o.open({ type: 'runform', toolCallId: 'call_1', label: 'Refund' }).status).toBe(
'focused'
)
expect(o.tabs).toHaveLength(2)
o.retargetRunForm('call_1', `${base}/run/01a0?workspace=w`)
// Still second: a tab that followed its call to the run has not moved in the strip.
expect(o.tabs[1].id).toBe(id)
expect(o.tabs[1].url).toBe(`${base}/run/01a0?workspace=w`)
})
it('re-points the same tab (no duplicate) when the artifact was renamed', () => {
const o = owner()
o.open(artifactTarget)
@@ -61,6 +61,7 @@ import {
previewLocationContext,
previewLocationLabel,
promptSafe,
parseRunFormRoute,
resolvePreviewTab
} from './previewRouter'
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
@@ -493,7 +494,13 @@ function createRuntime(session: Session): SessionRuntime {
const slot = resolvePreviewTab(url)
logFeatureUsage('ai_session', 'tab', {
key:
slot.kind === 'editor' ? slot.editorKind : slot.kind === 'artifact' ? 'artifact' : 'page',
slot.kind === 'editor'
? slot.editorKind
: slot.kind === 'artifact'
? 'artifact'
: slot.kind === 'runform'
? 'run_form'
: 'page',
entityId: session.id,
workspace: getEffectiveWorkspaceId(session)
})
@@ -511,6 +518,23 @@ function createRuntime(session: Session): SessionRuntime {
})
}
// Not a page: the tab mounts the chat's own form on the same tool call, so Run in the
// panel is Run in the chat, and the two share one draft rather than being two forms
// proposing two jobs.
manager.openRunForm = ({ toolCallId, label }) => {
previewTabs.open({ type: 'runform', toolCallId, label })
}
manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)
manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => {
previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`)
}
// Read off the tab list rather than the slot's lifecycle: a tab the user has switched
// away from is unmounted but still open, and the card must keep its form hidden until it
// is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes
// to `tabs` through it, and the runtime is not inside an effect root to push from.
manager.isRunFormInPreview = (toolCallId) =>
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId)
manager.openArtifact = (id, name, version) => {
previewTabs.open({ type: 'artifact', id, name, version })
}
@@ -61,6 +61,7 @@
matchPreviewPage,
pageKey,
parseArtifactRoute,
parseRunFormRoute,
parsePreviewItemRoute,
previewLocationLabel,
type PreviewTarget
@@ -493,7 +494,12 @@
const displayPath = $derived(owner?.activeTab?.loc ?? owner?.activeTab?.url ?? `${base}/`)
// Artifacts have no workspace page, so "Open in workspace" can't resolve for them.
const activeArtifact = $derived(owner?.activeTab ? parseArtifactRoute(owner.activeTab.url) : null)
const activeTabIsArtifact = $derived(activeArtifact != null)
// Nor does a run form: it belongs to a chat, and its url is a scheme rather than a path,
// so the link would resolve to the tool call id as a route.
const activeTabHasNoWorkspacePage = $derived(
activeArtifact != null ||
(owner?.activeTab ? parseRunFormRoute(owner.activeTab.url) != null : false)
)
// The active session's artifacts, surfaced as an "Artifacts" branch in the
// preview pickers.
const sessionArtifacts = $derived(activeRuntime?.manager.artifacts.artifacts ?? [])
@@ -931,7 +937,7 @@
<!-- Open-in-full-page + full-screen toggle, floating over the top-right
corner to mirror the collapse control. -->
<div class="absolute top-1 right-1 z-30 flex items-center gap-0.5">
{#if !activeTabIsArtifact}
{#if !activeTabHasNoWorkspacePage}
<a
href={withWorkspaceParam(
owner?.activeTab?.loc || owner?.activeTab?.url || `${base}/`,