mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
041ac95dc4
commit
6f9c4dc294
@@ -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 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user