diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index edd1704bb2..4aff51d002 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -9,6 +9,7 @@ import DisplayResult from '$lib/components/DisplayResult.svelte' import { msToReadableTime } from '$lib/utils' import JobArgs from '$lib/components/JobArgs.svelte' + import { JobService, type CompletedJob, type Job } from '$lib/gen' import { base } from '$lib/base' import { getAiChatManager } from './aiChatManagerContext' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' @@ -16,51 +17,108 @@ import ToolContentDisplay from './ToolContentDisplay.svelte' import ToolPreviewCard from './ToolPreviewCard.svelte' import { scrollFades } from './scrollFades.svelte' - import { isActiveRunForm, MAX_LOG_LENGTH, type ToolDisplayMessage } from './shared' + import { + deriveChatJobStatus, + isActiveRunForm, + MAX_LOG_LENGTH, + type ToolDisplayMessage + } from './shared' const aiChatManager = getAiChatManager() + const LOGS_UNREADABLE = { logs: '', logsFailed: true } as const + interface Props { message: ToolDisplayMessage } let { message }: Props = $props() - const runForm = $derived(message.runForm!) - const runnableKind = $derived(runForm.runnableKind ?? 'script') + const runForm = $derived(message.runForm) + // The run this call inspected instead of starting. Exclusive with runForm: a call + // either ran something or looked at a run. + const inspected = $derived(message.inspectedRun) // The loop is parked on the form and nothing has run yet: the card is the form. const pending = $derived(isActiveRunForm(message)) + // An inspected run is read from the job itself rather than from the tool result, which + // is the copy capped for the model. Fetched on first expand, and only then: a transcript + // of collapsed inspections must not fire a request per row. + let fetched = $state< + { callId: string; job: Job; logs: string; logsFailed?: boolean } | undefined + >(undefined) + let fetchFailed = $state(undefined) + // Keyed by call id like the rest of this card's state, not by the job it read: a summarized + // transcript hands a surviving instance a different row, and two rows can inspect one job + // at different moments, so a job-keyed snapshot would serve the earlier row's reading. + const inspectedJob = $derived(fetched?.callId === message.tool_call_id ? fetched : undefined) + + const inspectedStatus = $derived(inspectedJob ? deriveChatJobStatus(inspectedJob.job) : undefined) + const chatJob = $derived( aiChatManager.backgroundJobs.find((j) => j.toolCallId === message.tool_call_id) ) + // The run the card is about, whichever way it got here. The inspected one is known by + // id before it is read, which is what lets the preview chip work on a collapsed card. + const job = $derived( + chatJob ?? + (inspected + ? { + jobId: inspected.jobId, + workspace: inspected.workspace, + status: inspectedStatus + } + : undefined) + ) + const runnableKind = $derived( + runForm?.runnableKind ?? + (inspectedJob?.job.job_kind === 'flow' || inspectedJob?.job.job_kind === 'flowpreview' + ? 'flow' + : 'script') + ) // 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' + Boolean(message.declinedByUser) || Boolean(runForm?.canceled) || job?.status === 'canceled' + ) + const failed = $derived( + inspected ? inspectedStatus === 'failure' : Boolean(message.error) && !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 + Boolean(runForm?.started) || Boolean(message.logs) || message.result !== undefined || !!job ) // 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. + // the background poller lands an outcome on it or the tray sees the job end. An + // inspected run has no poller behind it — the status it was read at is the answer. const settled = $derived( - !pending && - !message.isLoading && - (message.result !== undefined || - failed || - canceled || - (chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status))) + inspected + ? ['success', 'failure', 'canceled'].includes(inspectedStatus ?? '') + : !pending && + !message.isLoading && + (message.result !== undefined || + failed || + canceled || + (chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status))) ) const running = $derived(!pending && !settled) + // The job the card is about has not been read yet, or could not be: no pane has anything + // to show, but the call's own result still has. + const jobPending = $derived(Boolean(inspected) && !inspectedJob) + // An inspected run's panes come from the job: the tool's own parameters are the + // address it was called with, and its result is the model's abridged view. const parameters = $derived( - message.parameters && typeof message.parameters === 'object' ? message.parameters : {} + inspected + ? (inspectedJob?.job.args ?? {}) + : message.parameters && typeof message.parameters === 'object' + ? message.parameters + : {} + ) + const logs = $derived( + inspected ? (inspectedJob?.logs ?? '') : typeof message.logs === 'string' ? message.logs : '' ) - 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. @@ -74,6 +132,12 @@ // 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(() => { + // Only a completed job carries a result; a queued or running one has none, and reading + // it off that job is a type error rather than an undefined. + if (inspected) + return inspectedJob && 'success' in inspectedJob.job + ? (inspectedJob.job as CompletedJob).result + : undefined if (message.result === undefined) return undefined if (typeof message.result !== 'string') return message.result try { @@ -82,20 +146,32 @@ 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' + runForm?.kind === 'test' ? { present: 'Testing', past: 'Tested', future: 'Test' } : { present: 'Running', past: 'Ran', future: 'Run' } ) + // Where the runnable is filed, for the preview chip's title. An inspected preview run + // has no path at all, so the card falls back to naming the job. + const path = $derived(runForm?.path ?? inspectedJob?.job.script_path ?? '') // 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) + // An inspection names the run instead: it is about that run, the address is what the call + // was made with, and naming the runnable would rewrite the row once the job is read. + const runnableName = $derived( + inspected + ? `${inspected.step ? `step ${inspected.step} of ` : ''}run ${inspected.runId}` + : runForm?.summary || runForm?.path || '' + ) + // Inspecting is done the moment the tool returned, whatever the run it looked at is + // still doing — the tense belongs to the call, not to its subject. + const verb = $derived( + inspected ? 'Inspected' : 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. @@ -130,9 +206,63 @@ 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. + // reusing this instance. Open by default, since the run is what was asked for — + // except for an inspection, which is usually a step in the reasoning rather than + // the answer, and which pays a fetch for being opened. let toggled = $state<{ id: string; open: boolean } | undefined>(undefined) - const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : true) + const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : !inspected) + + $effect(() => { + const target = inspected + // Collapsing clears a failure so reopening tries again, which is how the rest of the + // chat treats a load that did not land — a dropped connection must not be permanent. + if (!target || !expanded) { + fetchFailed = undefined + // A job whose logs did not land is dropped with it: the job itself is cached, so + // reopening would otherwise keep serving the unreadable logs for the session. + if (fetched?.logsFailed) fetched = undefined + return + } + const callId = message.tool_call_id + if (fetched?.callId === callId || fetchFailed === callId) return + const jobReq = JobService.getJob({ + workspace: target.workspace, + id: target.jobId, + noCode: true, + noLogs: true + }) + // The dedicated endpoint, as get_run uses it, and the whole log does come down for a + // 4000-char tail. The cheap reads cannot replace it: the job's own `logs` field is + // `right(job_logs.logs, 20000)`, and compaction leaves as few as 3000 characters in + // that column, so a large log would show less here than the model was given. + const logsReq = JobService.getJobLogs({ + workspace: target.workspace, + id: target.jobId, + removeAnsiWarnings: true + }) + let live = true + Promise.all([ + // Something back is the success test, not "it did not throw": the generated client + // resolves nothing when it cannot read the body. + jobReq.then((j) => j ?? Promise.reject(new Error('job unreadable'))), + // Here an empty string is a real answer — a flow's own job prints nothing. + logsReq.then( + (l) => (typeof l === 'string' ? { logs: l.slice(-MAX_LOG_LENGTH) } : LOGS_UNREADABLE), + () => LOGS_UNREADABLE + ) + ]) + .then(([j, l]) => { + if (live) fetched = { callId, job: j, ...l } + }) + .catch(() => { + if (live) fetchFailed = callId + }) + return () => { + live = false + jobReq.cancel() + logsReq.cancel() + } + }) // 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 @@ -217,7 +347,7 @@ if (canceled) return 'text-tertiary' if (failed) return 'text-red-800 dark:text-red-300' if (!ran) return 'text-tertiary' - switch (chatJob?.status) { + switch (job?.status) { case 'running': return 'text-blue-800 dark:text-blue-200' case 'suspended': @@ -248,16 +378,16 @@ ? aiChatManager.openRunForm ? ('form' as const) : undefined - : chatJob + : job ? ('run' as const) : undefined ) const previewTitle = $derived( previewTarget === 'form' - ? `Open this form in the preview panel: ${runForm.path}` + ? `Open this form in the preview panel: ${path}` : aiChatManager.openRunInPreview - ? `Open this run in the preview panel: ${runForm.path}` - : `Open this run in a new tab: ${runForm.path}` + ? `Open this run in the preview panel: ${path || runnableName}` + : `Open this run in a new tab: ${path || runnableName}` ) function openPreview() { @@ -266,16 +396,12 @@ aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label }) return } - if (!chatJob) return + if (!job) 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 }) + aiChatManager.openRunInPreview({ jobId: job.jobId, workspace: job.workspace, label }) } else { - window.open( - `${base}/run/${chatJob.jobId}?workspace=${chatJob.workspace}`, - '_blank', - 'noreferrer' - ) + window.open(`${base}/run/${job.jobId}?workspace=${job.workspace}`, '_blank', 'noreferrer') } } @@ -285,7 +411,9 @@ 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} + + {#if !pending && !inspected} {statusTime} @@ -297,7 +425,7 @@ tab it already opened. The row's only control, as on every other tool call. --> {#snippet previewChip()} These inputs are open in the preview panel. - {:else if pending} + {:else if pending && runForm} {:else} -
+
+
+ {#if fetchFailed === message.tool_call_id} + This run could not be read. It may have been deleted, or be in another workspace. + Its result is on the JSON toggle. + {:else} + + + Loading this run... + + {/if} +
{:else} {:else if activeTab === 'logs'} @@ -433,15 +580,25 @@ >{logs} {:else} -

No logs yet.

+

+ {inspectedJob?.logsFailed + ? 'Logs could not be read.' + : running + ? 'No logs yet.' + : 'No logs.'} +

{/if} - {#if running} + {#if running && !inspected}
streaming
{/if} - {:else if failed} + + {:else if !inspected && failed}
{message.error}
@@ -465,8 +622,8 @@ which the row already owns. --> diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 42df0f1e84..5405a6ea25 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -129,7 +129,9 @@ // 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)) + // A call that inspected a run rather than starting one gets the same card, bound to + // the job it named — what happened in a run reads the same either way. + const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun)) // 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. diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 825fdd34c3..8dec692e16 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -875,6 +875,67 @@ describe('global AI tools', () => { ) }) + it('names the job the card renders, without changing what the model is handed', async () => { + const runResult = await callGlobalTool('get_run', { id: 'job-123' }) + expect(toolCallbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-get_run', + expect.objectContaining({ + result: runResult, + inspectedRun: { + jobId: 'job-123', + workspace: WORKSPACE, + runId: 'job-123', + step: undefined + } + }) + ) + + // A step is a job of its own, and the model's line of prose about it carries + // neither its arguments nor its logs — the card reads those from the job. The + // address travels with it, since a step job names neither the step nor its run. + vi.mocked(JobService.getFlowAllResults).mockResolvedValueOnce({ + entries: [ + { + job_id: 'step-job-1', + label: 'b', + kind: 'script', + depth: 1, + sibling_index: 1, + sibling_count: 1, + status: 'success', + success: true, + result_prefix: '{"ok":true}' + } + ] + } as any) + const stepResult = await callGlobalTool('get_run', { id: 'job-123', step: 'b' }) + expect(stepResult).toContain('(job step-job-1, success) result:') + expect(toolCallbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-get_run', + expect.objectContaining({ + result: stepResult, + inspectedRun: { + jobId: 'step-job-1', + workspace: WORKSPACE, + runId: 'job-123', + step: 'b' + } + }) + ) + + // An address naming several jobs resolves to none of them, so there is + // nothing for the card to bind to and the call renders as an ordinary row. + vi.mocked(JobService.getFlowAllResults).mockResolvedValueOnce({ + entries: [], + step_error: 'Step "b" ran 4 times (loop/branches) — pick one with "b[i]".' + } as any) + await callGlobalTool('get_run', { id: 'job-123', step: 'b' }) + expect(toolCallbacks.setToolStatus).toHaveBeenLastCalledWith( + 'test-get_run', + expect.not.objectContaining({ inspectedRun: expect.anything() }) + ) + }) + it('reports when a run has no logs, and tells that apart from logs it could not read', async () => { vi.mocked(JobService.getJobLogs).mockResolvedValueOnce(' ') expect(JSON.parse(await callGlobalTool('get_run', { id: 'job-empty' })).run.logs).toBe( diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index b9a2cc7a91..ad077752c3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -3825,14 +3825,18 @@ export const globalTools: Tool<{}>[] = [ ? `Fetching result of step ${parsed.step} in run ${parsed.id}...` : `Inspecting run ${parsed.id}...` }) - const result = await getRun(workspace, parsed.id, parsed.step) + const { text, jobId } = await getRun(workspace, parsed.id, parsed.step) toolCallbacks.setToolStatus(toolId, { content: parsed.step ? `Fetched result of step ${parsed.step} in run ${parsed.id}` : `Inspected run ${parsed.id}`, - result + result: text, + // The card reads the run itself from here; the model only ever gets `text`. + ...(jobId + ? { inspectedRun: { jobId, workspace, runId: parsed.id, step: parsed.step } } + : {}) }) - return result + return text } }, { diff --git a/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts b/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts index 7f63de373b..7b81a70d7c 100644 --- a/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts +++ b/frontend/src/lib/components/copilot/chat/global/flowRunTree.ts @@ -410,22 +410,25 @@ function diagnoseRun(job: Job): Record { } } +/** What get_run answers with: the model's payload, and — when the call addresses one + * job — that job's id, which the card renders the run from. The two are separate + * audiences: `text` is capped for the model, `jobId` is how the user gets the whole + * thing. An address resolving to several jobs (a loop's `b`), an unfinished step or an + * unknown one carries no id, and the call renders as an ordinary tool row. */ +export type RunInspection = { text: string; jobId?: string } + /** Entry point of the get_run tool. Without `step`: the run's summary, args, * result and logs, plus the per-step tree when the run has steps. With `step`: * that step's full (capped) result, resolved server-side. */ -export async function getRun(workspace: string, id: string, step?: string): Promise { +export async function getRun(workspace: string, id: string, step?: string): Promise { if (!step) { // Only the job read is load-bearing: logs and the step tree each answer // part of the question, so neither failing should cost the model the rest. const [job, logs, results] = await Promise.all([ JobService.getJob({ workspace, id, noLogs: true, noCode: true }), - // The dedicated endpoint rather than the job's own `logs` field: that one - // is the last 20k still in the DB column, missing the head that log - // compaction flushed to object storage. This one stitches them back. - // - // It takes no length parameter, so unlike args and result the whole log - // does come into the tab before being capped. Only this job's own logs, - // though: a flow's are its orchestration lines, not its steps'. + // The dedicated endpoint rather than the job's own `logs` field, which holds too + // little to serve a tail — RunScriptCard's fetch has the mechanism. Only this + // job's own logs either way: a flow's are its orchestration lines, not its steps'. JobService.getJobLogs({ workspace, id, @@ -452,15 +455,18 @@ export async function getRun(workspace: string, id: string, step?: string): Prom : logs.trim() ? cap(logs, true) : 'No logs for this run.' - return shapeFlowRunTree(results, { - ...summary, - ...diagnoseRun(job), - // A successful read always carries the job itself as the root entry, so - // no entries means the read failed — and nothing else would name the run. - ...(results.entries.length === 0 ? { job_id: id, steps_unavailable: true } : {}), - ...payloads, - logs: shapedLogs - }) + return { + text: shapeFlowRunTree(results, { + ...summary, + ...diagnoseRun(job), + // A successful read always carries the job itself as the root entry, so + // no entries means the read failed — and nothing else would name the run. + ...(results.entries.length === 0 ? { job_id: id, steps_unavailable: true } : {}), + ...payloads, + logs: shapedLogs + }), + jobId: id + } } return getStepResult(workspace, id, step) @@ -469,7 +475,7 @@ export async function getRun(workspace: string, id: string, step?: string): Prom /** One step's result in full, addressed by step path. The server resolves the * address directly (a few indexed lookups, no tree enumeration) and returns the * single job as an entry. */ -async function getStepResult(workspace: string, id: string, step: string): Promise { +async function getStepResult(workspace: string, id: string, step: string): Promise { const response = await JobService.getFlowAllResults({ workspace, id, @@ -477,27 +483,38 @@ async function getStepResult(workspace: string, id: string, step: string): Promi step }) if (response.step_error) { - return ( - response.step_error + - (response.scope_filtered - ? ' (Steps running on tags outside your token’s scope are hidden.)' - : '') - ) + return { + text: + response.step_error + + (response.scope_filtered + ? ' (Steps running on tags outside your token’s scope are hidden.)' + : '') + } } const entry = response.entries[0] if (!entry) { - return 'No jobs found for this run.' + return { text: 'No jobs found for this run.' } } if (entry.status === 'running' || entry.status === 'queued' || entry.status === 'suspended') { - return `Step "${step}" (job ${entry.job_id}) has not completed yet — status: ${entry.status}.` + return { + text: `Step "${step}" (job ${entry.job_id}) has not completed yet — status: ${entry.status}.` + } } + // Every completed step is a job of its own, so the card renders it from source — + // including a skipped one, whose inputs and logs are all there is to see. if (entry.result_prefix === undefined || entry.result_prefix === null) { - return `Step "${step}" (job ${entry.job_id}, ${entry.status}) has no recorded result.` + return { + text: `Step "${step}" (job ${entry.job_id}, ${entry.status}) has no recorded result.`, + jobId: entry.job_id + } } const total = entry.result_length ?? countCodePoints(entry.result_prefix) const capped = total > countCodePoints(entry.result_prefix) ? entry.result_prefix + `\n… (result truncated: ${total} chars total)` : entry.result_prefix - return `Step "${step}" (job ${entry.job_id}, ${entry.status}) result:\n${capped}` + return { + text: `Step "${step}" (job ${entry.job_id}, ${entry.status}) result:\n${capped}`, + jobId: entry.job_id + } } diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 94f58f7f13..dd84e479de 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -640,6 +640,13 @@ export type ToolDisplayMessage = { actions?: ToolDisplayAction[] userQuestion?: UserQuestionDisplay runForm?: RunFormDisplay + /** A run this call inspected rather than started, rendered by the same card. The card + * reads its panes from this job, so the user sees its own args and result in full, and its + * logs as a 4000-char tail, while the model keeps the capped envelope the tool returned. + * `runId` and `step` are the address the call was made with, kept so the card can name what + * was inspected the way the tool's own row did: a step job names neither the step nor the + * run it belongs to. */ + inspectedRun?: { jobId: string; workspace: string; runId: string; step?: string } webSearchSources?: WebSearchSource[] /** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */ imageUrl?: string