refactor: retire AIAgentLogViewer in favour of the transcript

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-09-14 09:10:51 +02:00
co-authored by Claude Opus 5
parent a04ec30291
commit 8482524ff8
7 changed files with 39 additions and 382 deletions
@@ -1,281 +0,0 @@
<script lang="ts">
import type { GraphModuleState } from './graph'
import {
JobService,
type CompletedJob,
type FlowModule,
type FlowStatusModule,
type Job
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import FlowLogViewerWrapper from './FlowLogViewerWrapper.svelte'
import { z } from 'zod'
import { untrack } from 'svelte'
import type { AgentTool } from './flows/agentToolUtils'
type AgentActionWithContent = NonNullable<FlowStatusModule['agent_actions']>[number] & {
content?: unknown
}
const resultSchema = z.object({
messages: z.array(
z.object({
role: z.string(),
content: z.unknown(),
agent_action: z
.union([
z.object({
type: z.literal('tool_call'),
job_id: z.string(),
module_id: z.string(),
function_name: z.string()
}),
z.object({
type: z.literal('mcp_tool_call'),
call_id: z.string(),
function_name: z.string(),
resource_path: z.string(),
arguments: z.record(z.any(), z.any()).optional()
}),
z.object({
type: z.literal('message')
}),
z.object({
type: z.literal('web_search')
})
])
.optional()
})
)
})
interface Props {
tools: AgentTool[]
agentJob: Partial<CompletedJob> & Pick<CompletedJob, 'id'> & { type: 'CompletedJob' }
workspaceId?: string | undefined
storedToolCallJobs?: Record<number, Job>
onToolJobLoaded?: (job: Job, idx: number) => void
noPadding?: boolean
}
let {
tools,
agentJob,
workspaceId,
onToolJobLoaded,
storedToolCallJobs,
noPadding = false
}: Props = $props()
const fakeModuleStates: Record<string, GraphModuleState> = $state({})
async function loadMissingJobs(
agentActions: AgentActionWithContent[],
gen: number
): Promise<Record<string, GraphModuleState>> {
const states: Record<string, GraphModuleState> = {}
const promises = agentActions.map(async (toolCall, idx) => {
if (toolCall.type === 'tool_call') {
let job: Job | undefined = storedToolCallJobs?.[idx]
if (!job || job.type !== 'CompletedJob') {
job = await JobService.getJob({
id: toolCall.job_id,
workspace: workspaceId ?? $workspaceStore!
})
}
states[idx.toString()] = {
args: job.args,
type: job['success'] ? 'Success' : 'Failure',
logs: job.logs,
result: job['result'],
job_id: toolCall.job_id
}
// Keyed by index in the parent's cache, so a superseded run must not write into it.
if (gen === loadGen) {
onToolJobLoaded?.(job, idx)
}
} else if (toolCall.type === 'mcp_tool_call') {
states[idx.toString()] = {
type: 'Success',
args: toolCall.arguments ?? {},
logs: '',
result: toolCall.content
}
} else if (toolCall.type === 'web_search') {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
result: toolCall.content
}
} else {
states[idx.toString()] = {
type: 'Success',
args: {},
logs: '',
result: toolCall.content
}
}
})
await Promise.all(promises)
return states
}
let job: Partial<Job> | undefined = $state(undefined)
// Every prop change starts another load; only the newest may write the shared view, else a
// slower reload for a previous run restores its logs over the one now selected.
let loadGen = 0
async function loadToolCalls(agentJob: Props['agentJob'], tools: AgentTool[]) {
const gen = ++loadGen
let parsedResult = resultSchema.safeParse(agentJob.result)
if (!parsedResult.success) {
console.error('Invalid result', parsedResult.error)
// A failed agent job has no parseable action list. Drop the view rather than leave the
// previously selected step's tool tree rendered under this one's header.
if (gen === loadGen) {
job = undefined
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
}
return
}
let agentActions = parsedResult.data.messages
.map(
(m) =>
(m.agent_action?.type === 'message'
? {
type: 'message',
content: m.content
}
: m.agent_action?.type === 'tool_call'
? {
type: 'tool_call',
job_id: m.agent_action.job_id,
module_id: m.agent_action.module_id,
function_name: m.agent_action.function_name
}
: m.agent_action?.type === 'mcp_tool_call'
? {
type: 'mcp_tool_call',
content: m.content,
call_id: m.agent_action.call_id,
function_name: m.agent_action.function_name,
arguments: m.agent_action.arguments
}
: m.agent_action?.type === 'web_search'
? {
type: 'web_search',
content: m.content
}
: undefined) as AgentActionWithContent | undefined
)
.filter((m) => m !== undefined)
const states = await loadMissingJobs(agentActions, gen)
if (gen !== loadGen) {
return
}
for (const key of Object.keys(fakeModuleStates)) {
delete fakeModuleStates[key]
}
Object.assign(fakeModuleStates, states)
job = {
...agentJob,
raw_flow: {
modules: agentActions
.map((toolCall, idx) => {
if (toolCall.type === 'message') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
}
}
} else if (toolCall.type === 'mcp_tool_call') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
},
summary: toolCall.function_name,
arguments: toolCall.arguments
}
} else if (toolCall.type === 'web_search') {
return {
id: idx.toString(),
value: {
type: 'identity' as const
},
summary: 'Web Search'
}
} else {
const module = tools.find((m) => m.summary === toolCall.function_name)
// A definition can be missing for a call that did run: the tool was renamed or
// removed since, or it belongs to a linked agent whose resource is no longer
// readable. Keep the recorded call — its args, logs and result come from the
// child job — rather than dropping it from the history.
return module
? ({
...module,
id: idx.toString()
} as FlowModule)
: ({
id: idx.toString(),
value: { type: 'identity' as const },
summary: toolCall.function_name
} as FlowModule)
}
})
}
}
}
// Identity, not a summary digest: a refreshed resource can change a tool's path, code or id while
// keeping its name and count. The store swaps the array only when its contents actually differ,
// so one version per array instance tracks that exactly. An empty list is always the same key,
// since callers hand out a fresh [] for it on every render.
const toolsVersions = new WeakMap<object, number>()
let nextToolsVersion = 0
function toolsIdentity(list: AgentTool[]): string {
if (list.length === 0) {
return 'empty'
}
let version = toolsVersions.get(list)
if (version === undefined) {
version = ++nextToolsVersion
toolsVersions.set(list, version)
}
return String(version)
}
// Rebuild when the inputs change, not only on mount: a linked agent's tools resolve
// asynchronously after the first render, and switching between completed runs reuses this
// component — either would otherwise keep the first snapshot. Keyed by value, because callers
// rebuild the `agentJob` object on every render and identity alone would reload in a loop.
let reloadKey = $derived(`${agentJob?.id ?? ''}|${toolsIdentity(tools)}`)
$effect(() => {
reloadKey
untrack(() => {
if (agentJob) {
loadToolCalls(agentJob, tools)
}
})
})
</script>
{#if job}
<div class={noPadding ? '' : 'p-2'}>
<FlowLogViewerWrapper
{job}
localModuleStates={fakeModuleStates}
{workspaceId}
render={true}
onSelectedIteration={async () => {}}
mode="aiagent"
/>
</div>
{/if}
@@ -2,10 +2,9 @@
import { Loader2 } from 'lucide-svelte'
import DisplayResult from './DisplayResult.svelte'
import LogViewer from './LogViewer.svelte'
import type { CompletedJob, Job } from '$lib/gen'
import AiAgentLogViewer from './AIAgentLogViewer.svelte'
import { twMerge } from 'tailwind-merge'
import type { AgentTool } from './flows/agentToolUtils'
import AgentTranscript from './AgentTranscript.svelte'
import { parseAgentResult } from './aiAgentResult'
interface Props {
waitingForExecutor?: boolean
@@ -21,12 +20,6 @@
refreshLog?: boolean
downloadLogs?: boolean
tagLabel?: string | undefined
aiAgentStatus?: {
tools: AgentTool[]
agentJob: Partial<CompletedJob> & Pick<CompletedJob, 'id'> & { type: 'CompletedJob' }
storedToolCallJobs?: Record<number, Job>
onToolJobLoaded?: (job: Job, idx: number) => void
}
}
let {
@@ -41,9 +34,12 @@
tag = undefined,
workspaceId = undefined,
downloadLogs = true,
tagLabel = undefined,
aiAgentStatus = undefined
tagLabel = undefined
}: Props = $props()
// An agent step's own logs are worker chatter; what happened is its conversation.
// Derived from the result rather than passed in, so every caller gets it.
let agentResult = $derived(parseAgentResult(result))
</script>
<div
@@ -69,9 +65,11 @@
</div>
</div>
<div class="relative flex flex-col gap-1">
<span class="text-emphasis text-xs font-semibold">Logs</span>
{#if aiAgentStatus}
<AiAgentLogViewer {...aiAgentStatus} {workspaceId} noPadding />
<span class="text-emphasis text-xs font-semibold">{agentResult ? 'Transcript' : 'Logs'}</span>
{#if agentResult}
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-auto p-2">
<AgentTranscript messages={agentResult.messages} {workspaceId} />
</div>
{:else}
<div class="rounded-md grow min-h-0 border bg-surface-tertiary overflow-hidden">
<LogViewer
@@ -49,7 +49,6 @@
) => Promise<void>
getSelectedIteration: (stepId: string) => number
flowSummary?: string
mode?: 'flow' | 'aiagent'
currentId?: string | null
navigationChain?: NavigationChain
select: (id: string) => void
@@ -81,7 +80,6 @@
onSelectedIteration,
getSelectedIteration,
flowSummary,
mode = 'flow',
currentId,
navigationChain = $bindable(),
select,
@@ -127,7 +125,7 @@
function getStepProgress(job: RootJobData | undefined, totalSteps: number): string {
if (!job || totalSteps === 0) return ''
const stepWord = mode === 'aiagent' ? 'action' : 'step'
const stepWord = 'step'
// If flow is completed, show total steps
if (job.type === 'CompletedJob') {
@@ -558,7 +556,7 @@
{@render flowIcon(getFlowStatus(rootJob), flowInfo?.hasErrors)}
<div class="text-xs text-left font-mono">
{mode === 'aiagent' ? 'AI Agent' : level == 0 ? 'Flow' : 'Subflow'}
{level == 0 ? 'Flow' : 'Subflow'}
{#if flowInfo?.label}
: {flowInfo.label}
{/if}
@@ -703,32 +701,22 @@
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-left">
<b class="flex items-center gap-1">
{#if mode === 'aiagent'}
{#if module.summary}
Tool call: {module.summary}
{:else}
Message
{/if}
{:else}
{module.id}
{/if}
{module.id}
</b>
{#if mode === 'flow'}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
{#if module.value.type === 'forloopflow'}
For loop
{:else if module.value.type === 'whileloopflow'}
While loop
{:else if module.value.type === 'branchall'}
Branch to all
{:else if module.value.type === 'branchone'}
Branch to one
{:else if module.value.type === 'flow'}
Subflow
{:else}
Step
{/if}
{#if module.summary && mode !== 'aiagent'}
{#if module.summary}
: {module.summary}
{/if}
{#if hasEmptySubflowValue}
@@ -20,7 +20,6 @@
| { id: string; index: number; manuallySet: true; moduleId: string }
| { manuallySet: false; moduleId: string }
) => Promise<void>
mode?: 'flow' | 'aiagent'
}
let {
@@ -30,7 +29,6 @@
workspaceId,
render,
onSelectedIteration,
mode = 'flow'
}: Props = $props()
// State for tracking expanded rows - using Record to allow explicit control
@@ -180,7 +178,6 @@
{render}
{getSelectedIteration}
flowId="root"
{mode}
{currentId}
bind:navigationChain
{select}
@@ -65,7 +65,6 @@
import { getActiveReplay } from './recording/replay.svelte'
import { publishLinkedAgentTools } from './flows/flowState'
import {
getLinkedAgentTools,
linkedToolsScope,
releaseLinkedToolsScope,
retainLinkedToolsScope
@@ -2142,15 +2141,6 @@
<p class="text-secondary">No arguments</p>
{/if}
{:else if node}
{@const module =
stepDetail && typeof stepDetail !== 'string' ? stepDetail : undefined}
{@const agentTools =
module && module.value.type === 'aiagent'
? module.value.agent
? getLinkedAgentTools(linkedToolsViewScope, module.id)
: (module.value.tools ?? [])
: undefined}
{@const parentLoopsPrefix = getParentLoopsPrefix(module?.id ?? '')}
{#if node.flow_jobs_results}
<div>
<span class="pl-1 text-emphasis text-xs font-medium"
@@ -2245,30 +2235,6 @@
tag={node.tag}
logs={node.logs}
downloadLogs={!hideDownloadLogs && !isReplay}
aiAgentStatus={agentTools &&
node?.job_id &&
(node.type === 'Success' || node.type === 'Failure')
? {
tools: agentTools,
agentJob: {
id: node.job_id,
result: node.result,
logs: node.logs,
args: node.args,
success: node.type === 'Success',
type: 'CompletedJob'
},
storedToolCallJobs: module
? toolCallStore?.getLocalToolCallJobs(parentLoopsPrefix)
: undefined,
onToolJobLoaded: (job, idx) => {
if (module) {
const storeKey = parentLoopsPrefix + module.id + '-' + idx
toolCallStore?.setStoredToolCallJob(storeKey, job)
}
}
}
: undefined}
/>
</div>
</div>
@@ -8,10 +8,10 @@
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import type { FlowEditorContext, OutputViewerJob } from './flows/types'
import type { AgentTool } from './flows/agentToolUtils'
import { getContext } from 'svelte'
import { getStringError } from './copilot/chat/utils'
import AiAgentLogViewer from './AIAgentLogViewer.svelte'
import AgentTranscript from './AgentTranscript.svelte'
import { parseAgentResult } from './aiAgentResult'
interface Props {
lang: Script['language']
@@ -27,9 +27,6 @@
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
loadingJob?: boolean
tagLabel?: string
// A linked agent persists no tools of its own; its resolved resource tools are passed here so
// the log viewer can label each tool_call with the definition that ran.
linkedAgentTools?: AgentTool[]
}
let {
@@ -45,8 +42,7 @@
disableHistory = false,
onUpdateMock,
loadingJob = false,
tagLabel = undefined,
linkedAgentTools = undefined
tagLabel = undefined
}: Props = $props()
const { stepsInputArgs, flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -61,6 +57,10 @@
)
const logJob = $derived(testJob ?? selectedJob)
const preview = $derived.by(() => outputPickerInner?.getPreview?.())
// The trace of an agent step is its conversation, which its own result carries.
const agentResult = $derived(
logJob?.type === 'CompletedJob' ? parseAgentResult(logJob.result) : undefined
)
</script>
<Splitpanes horizontal>
@@ -105,15 +105,10 @@
customEmptyMessage="Using pinned data"
{tagLabel}
/>
{:else if mod.value.type === 'aiagent' && logJob?.type === 'CompletedJob'}
<AiAgentLogViewer
tools={linkedAgentTools ?? mod.value.tools ?? []}
agentJob={{
...logJob,
type: 'CompletedJob'
}}
workspaceId={logJob.workspace_id}
/>
{:else if agentResult}
<div class="h-full overflow-auto p-2">
<AgentTranscript messages={agentResult.messages} workspaceId={logJob?.workspace_id} />
</div>
{:else}
<LogViewer
small
@@ -1460,12 +1460,6 @@
{testJob}
{scriptProgress}
mod={flowModule}
linkedAgentTools={agentLinked
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
linkedToolsModuleId
)
: undefined}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
disableHistory={failureModule}