feat(ai-chat): render get_run through the run tools' card (#11204)

* feat(ai-chat): render get_run through the run tools' card

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-chat): retry unreadable logs on reopen and drop the loading tint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): inline two single-use deriveds in the run card

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(ai-chat): record why an inspected run's logs need the dedicated endpoint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ai-chat): key an inspected run's fetch to the tool call, not the job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(ai-chat): say an inspected run's logs are a tail, not the whole log

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-09-18 14:16:52 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 041ac95dc4
commit 6f9c4dc294
6 changed files with 328 additions and 80 deletions
@@ -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<string | undefined>(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')
}
}
</script>
@@ -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}
<!-- An inspection reports nothing here: the card is a snapshot the chat will never
update, so a status on it would be frozen at whatever the run happened to be. -->
{#if !pending && !inspected}
<span class={twMerge('shrink-0 whitespace-nowrap text-2xs font-medium', statusClass)}>
{statusTime}
</span>
@@ -297,7 +425,7 @@
tab it already opened. The row's only control, as on every other tool call. -->
{#snippet previewChip()}
<ToolPreviewCard
card={{ kind: runnableKind, path: runForm.path }}
card={{ kind: runnableKind, path }}
title={previewTitle}
onOpen={openPreview}
kindIcon={false}
@@ -325,7 +453,7 @@
<div class="px-3 py-2 text-2xs leading-4 text-hint">
These inputs are open in the preview panel.
</div>
{:else if pending}
{:else if pending && runForm}
<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
@@ -333,7 +461,12 @@
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]')}>
<div
class={twMerge(
'relative flex flex-col',
rawView ? 'min-h-[20rem]' : jobPending ? '' : '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
@@ -345,7 +478,7 @@
wrapperClass="shrink-0"
slidingIndicator
>
{#if !rawView}
{#if !rawView && !jobPending}
{#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
@@ -388,7 +521,7 @@
class={twMerge(
'min-h-0 flex-1 px-3 py-2',
rawView ? '' : 'overflow-auto',
!rawView && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
!rawView && !jobPending && activeTab === 'logs' ? 'bg-surface-secondary/50' : ''
)}
>
<!-- min-h-full rather than h-full: the states that centre themselves need the height,
@@ -406,6 +539,20 @@
showFade
/>
</div>
{:else if jobPending}
<!-- The strip above stays whatever the job does: the call's own result is already
in the transcript, and the JSON toggle is how it is read. -->
<div class="text-2xs leading-4 text-hint">
{#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}
<span class="inline-flex items-center gap-1.5">
<Loader2 class="h-3 w-3 animate-spin" />
Loading this run...
</span>
{/if}
</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
@@ -418,8 +565,8 @@
persisted with the card. -->
<JobArgs
args={parameters}
id={chatJob?.jobId}
workspace={chatJob?.workspace}
id={job?.jobId}
workspace={job?.workspace}
disableExpand
/>
{:else if activeTab === 'logs'}
@@ -433,15 +580,25 @@
>{logs}</pre
>
{:else}
<p class="text-2xs text-tertiary">No logs yet.</p>
<p class="text-2xs text-tertiary">
{inspectedJob?.logsFailed
? 'Logs could not be read.'
: running
? 'No logs yet.'
: 'No logs.'}
</p>
{/if}
{#if running}
{#if running && !inspected}
<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}
<!-- A run the chat started reports its failure on the tool call; an inspected one
carries it as its result, which the pane below renders the way the run page
does. `failed` excludes a cancellation, which also leaves an error on the
message but owns its own pane. -->
{:else if !inspected && failed}
<pre
class="whitespace-pre-wrap break-words font-mono text-2xs text-red-700 dark:text-red-300"
>{message.error}</pre
@@ -452,8 +609,8 @@
<DisplayResult
result={undefined}
result_stream={resultStream}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
jobId={job?.jobId}
workspaceId={job?.workspace}
disableExpand
hideAsJson
/>
@@ -465,8 +622,8 @@
which the row already owns. -->
<DisplayResult
result={resultValue}
jobId={chatJob?.jobId}
workspaceId={chatJob?.workspace}
jobId={job?.jobId}
workspaceId={job?.workspace}
disableExpand
hideAsJson
/>
@@ -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.
@@ -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(
@@ -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
}
},
{
@@ -410,22 +410,25 @@ function diagnoseRun(job: Job): Record<string, unknown> {
}
}
/** 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<string> {
export async function getRun(workspace: string, id: string, step?: string): Promise<RunInspection> {
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<string> {
async function getStepResult(workspace: string, id: string, step: string): Promise<RunInspection> {
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 tokens scope are hidden.)'
: '')
)
return {
text:
response.step_error +
(response.scope_filtered
? ' (Steps running on tags outside your tokens 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
}
}
@@ -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