From 0e12f53d269eb343ff7c9e655b66eb0974a088ce Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 27 Mar 2026 16:20:13 -0700 Subject: [PATCH] ash view Signed-off-by: Peter Wielander --- .../web-shared/src/components/agent-view.tsx | 417 ++++++++++++++++++ packages/web-shared/src/components/index.ts | 1 + packages/web-shared/src/index.ts | 2 + packages/web-shared/src/lib/agent-scan.ts | 355 +++++++++++++++ .../web/app/components/run-detail-view.tsx | 115 ++++- 5 files changed, 883 insertions(+), 7 deletions(-) create mode 100644 packages/web-shared/src/components/agent-view.tsx create mode 100644 packages/web-shared/src/lib/agent-scan.ts diff --git a/packages/web-shared/src/components/agent-view.tsx b/packages/web-shared/src/components/agent-view.tsx new file mode 100644 index 0000000000..738d52f514 --- /dev/null +++ b/packages/web-shared/src/components/agent-view.tsx @@ -0,0 +1,417 @@ +'use client'; + +import type { Step, WorkflowRun } from '@workflow/world'; +import type { ModelMessage } from 'ai'; +import { Box, ExternalLink, Lock } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { type AgentScanMatch, scanRunAndSteps } from '../lib/agent-scan'; +import { isEncryptedMarker } from '../lib/hydration'; +import { ConversationView } from './sidebar/conversation-view'; +import { DecryptButton } from './ui/decrypt-button'; +import { Spinner } from './ui/spinner'; + +// ─── Types ────────────────────────────────────────────────────────── + +interface ConversationEntry { + /** Step ID or run ID */ + entityId: string; + /** Which field the conversation was found in */ + field: 'input' | 'output'; + messages: ModelMessage[]; + /** Timestamp for display (createdAt of the step, or undefined for run) */ + timestamp?: Date; +} + +interface UniqueSandbox { + sandboxName: string; + adapter: string; + sessionKey: string; +} + +// ─── Helpers ──────────────────────────────────────────────────────── + +/** Format a relative timestamp for the sidebar. */ +function formatRelativeTime(date: Date | undefined): string { + if (!date) return ''; + const now = Date.now(); + const diff = now - date.getTime(); + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return date.toLocaleDateString(); +} + +/** Collect unique sandboxes from all sandbox scan matches. */ +function collectUniqueSandboxes(matches: AgentScanMatch[]): UniqueSandbox[] { + const seen = new Map(); + for (const match of matches) { + if (match.type !== 'sandbox') continue; + const v = match.value as Record; + + if (Array.isArray(v.sessions)) { + for (const session of v.sessions as Record[]) { + const key = session.sessionKey as string; + if (!seen.has(key)) { + seen.set(key, { + sandboxName: session.sandboxName as string, + adapter: session.adapter as string, + sessionKey: key, + }); + } + } + } else if (typeof v.sessionKey === 'string') { + if (!seen.has(v.sessionKey as string)) { + seen.set(v.sessionKey as string, { + sandboxName: v.sandboxName as string, + adapter: v.adapter as string, + sessionKey: v.sessionKey as string, + }); + } + } + } + return Array.from(seen.values()); +} + +/** Collect per-step conversation entries, sorted by step ID (chronological). */ +function collectConversations( + matches: AgentScanMatch[], + steps: Step[] +): ConversationEntry[] { + const stepMap = new Map(steps.map((s) => [s.stepId, s])); + const entries: ConversationEntry[] = []; + for (const match of matches) { + if (match.type !== 'model-messages') continue; + const messages = match.value as ModelMessage[]; + if (messages.length === 0) continue; + const step = stepMap.get(match.source.entityId); + entries.push({ + entityId: match.source.entityId, + field: match.source.field, + messages, + timestamp: step?.createdAt, + }); + } + // Sort by step ID (ULID = chronological order) + entries.sort((a, b) => a.entityId.localeCompare(b.entityId)); + return entries; +} + +// ─── Sandbox panel (right column) ─────────────────────────────────── + +function SandboxPanel({ + sandboxes, + teamSlug, + projectSlug, +}: { + sandboxes: UniqueSandbox[]; + teamSlug?: string; + projectSlug?: string; +}) { + if (sandboxes.length === 0) return null; + + return ( +
+

+ Sandboxes +

+ {sandboxes.map((sandbox) => { + const canLink = Boolean(teamSlug && projectSlug); + const href = canLink + ? `https://vercel.com/${teamSlug}/${projectSlug}/sandboxes/${sandbox.sessionKey}` + : undefined; + + return ( +
+
+ + + + {sandbox.sandboxName} + + + + + {sandbox.adapter} + + {href && ( + + + + )} + +
+
+ ); + })} +
+ ); +} + +// ─── Step sidebar (left column) ───────────────────────────────────── + +function StepSidebar({ + conversations, + selectedIndex, + onSelect, +}: { + conversations: ConversationEntry[]; + selectedIndex: number; + onSelect: (index: number) => void; +}) { + return ( +
+

+ Steps +

+ {conversations.map((entry, i) => { + const isSelected = i === selectedIndex; + const stepNumber = i + 1; + return ( + + ); + })} +
+ ); +} + +// ─── Main component ───────────────────────────────────────────────── + +export interface AgentViewProps { + run: WorkflowRun; + steps: Step[]; + /** Whether step data is currently being loaded */ + isLoading?: boolean; + /** Callback to initiate decryption of encrypted run data */ + onDecrypt?: () => void; + /** Whether the encryption key is currently being fetched */ + isDecrypting?: boolean; + /** Encryption key (available after decryption) */ + encryptionKey?: Uint8Array; + /** Team slug for sandbox deep-links (e.g. "vercel-labs") */ + teamSlug?: string; + /** Project slug for sandbox deep-links (e.g. "d0-agent-ash") */ + projectSlug?: string; +} + +/** + * Top-level Agent tab view. Three-column layout: + * - Left: step list sidebar (conversations) + * - Center: chat history for the selected step + * - Right: sandbox resources + */ +export function AgentView({ + run, + steps, + isLoading, + onDecrypt, + isDecrypting, + encryptionKey, + teamSlug, + projectSlug, +}: AgentViewProps) { + const hasEncryptedData = useMemo(() => { + for (const entity of [run, ...steps]) { + const e = entity as Record; + if (isEncryptedMarker(e.input) || isEncryptedMarker(e.output)) { + return true; + } + } + return false; + }, [run, steps]); + + const needsDecryption = hasEncryptedData && !encryptionKey; + + const matches = useMemo(() => scanRunAndSteps(run, steps), [run, steps]); + + const conversations = useMemo( + () => collectConversations(matches, steps), + [matches, steps] + ); + + const sandboxes = useMemo(() => collectUniqueSandboxes(matches), [matches]); + + // Default to the longest conversation + const defaultIndex = useMemo(() => { + if (conversations.length === 0) return 0; + let maxIdx = 0; + let maxLen = 0; + for (let i = 0; i < conversations.length; i++) { + if (conversations[i].messages.length > maxLen) { + maxLen = conversations[i].messages.length; + maxIdx = i; + } + } + return maxIdx; + }, [conversations]); + + const [selectedIndex, setSelectedIndex] = useState(null); + const activeIndex = selectedIndex ?? defaultIndex; + + // ── Loading / encrypted / empty states ────────────────────────── + if (isLoading) { + return ( +
+ + + Loading step data… + +
+ ); + } + + if (needsDecryption) { + return ( +
+ +

+ Run data is encrypted. Decrypt to scan for agent data. +

+ {onDecrypt && ( + + )} +
+ ); + } + + if (matches.length === 0) { + return ( +
+ No agent data detected +
+ ); + } + + const activeConversation = conversations[activeIndex]; + + // ── Three-column layout ───────────────────────────────────────── + return ( +
+ {/* Left sidebar — step list */} + {conversations.length > 0 && ( +
+ +
+ )} + + {/* Center — conversation */} +
+ {activeConversation ? ( + <> +
+ {activeConversation.entityId} +
+
+ +
+ + ) : ( +
+ No conversations found +
+ )} +
+ + {/* Right sidebar — sandboxes / resources */} + {sandboxes.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/web-shared/src/components/index.ts b/packages/web-shared/src/components/index.ts index f62e0a5c0b..e7a7666716 100644 --- a/packages/web-shared/src/components/index.ts +++ b/packages/web-shared/src/components/index.ts @@ -1,3 +1,4 @@ +export { type AgentViewProps, AgentView } from './agent-view'; export { ErrorBoundary } from './error-boundary'; export { EventListView } from './event-list-view'; export type { diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 1fe5d55d1e..ddf340a0ed 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -49,6 +49,8 @@ export { STREAM_REF_TYPE, truncateId, } from './lib/hydration'; +export type { AgentScanMatch, AgentScanResultType } from './lib/agent-scan'; +export { hasAgentData, scanRunAndSteps, scanValue } from './lib/agent-scan'; export type { ToastAdapter } from './lib/toast'; export { ToastProvider, useToast } from './lib/toast'; export type { StreamStep } from './lib/utils'; diff --git a/packages/web-shared/src/lib/agent-scan.ts b/packages/web-shared/src/lib/agent-scan.ts new file mode 100644 index 0000000000..4de59b190f --- /dev/null +++ b/packages/web-shared/src/lib/agent-scan.ts @@ -0,0 +1,355 @@ +/** + * Optimistic deep-inspection of run/step inputs and outputs for agent-related + * data structures. Each scanner is called recursively; the first match wins + * for a given subtree (no further descent into matched objects). + * + * Designed to be easy to extend — add a new scanner function to `scanners`. + */ + +import type { Step, WorkflowRun } from '@workflow/world'; +import type { ModelMessage } from 'ai'; +import { isEncryptedMarker } from './hydration'; + +// ─── Scan result types ────────────────────────────────────────────── + +/** Display types the UI currently understands. */ +export type AgentScanResultType = 'sandbox' | 'model-messages'; + +export interface AgentScanMatch { + type: AgentScanResultType; + value: unknown; + source: { + entityType: 'run' | 'step'; + entityId: string; + field: 'input' | 'output'; + }; +} + +// ─── Scanner interface ────────────────────────────────────────────── + +/** + * A scanner inspects a single value at the current recursion depth. + * Return `[type, convertedObject]` on match, or `undefined` to skip. + */ +type Scanner = ( + value: unknown, + depth: number +) => [AgentScanResultType, unknown] | undefined; + +// ─── ToolLoopTranscriptMessage → ModelMessage conversion ──────────── +// Ported from ash: provider-messages.ts toLanguageModelMessages / toToolResultPart + +function toToolResultPart(part: Record) { + const base = { + toolCallId: part.toolCallId as string, + toolName: part.toolName as string, + type: 'tool-result' as const, + }; + + if (part.isError) { + return { + ...base, + output: + typeof part.output === 'string' + ? { type: 'error-text' as const, value: part.output } + : { type: 'error-json' as const, value: part.output }, + }; + } + + return { + ...base, + output: { type: 'json' as const, value: part.output }, + }; +} + +/** + * Convert a ToolLoopTranscriptMessage array into AI-SDK-compatible + * ModelMessage[]. Logic mirrors `toLanguageModelMessages` in ash. + */ +export function toModelMessages(messages: unknown[]): ModelMessage[] { + return messages.map((raw) => { + const message = raw as Record; + + switch (message.role) { + case 'assistant': { + const content = message.content; + return { + role: 'assistant' as const, + content: + typeof content === 'string' + ? content + : (content as Record[]).map((part) => { + switch (part.type) { + case 'text': + return { + type: 'text' as const, + text: part.text as string, + }; + case 'tool-call': + return { + type: 'tool-call' as const, + toolCallId: part.toolCallId as string, + toolName: part.toolName as string, + input: part.input, + }; + case 'tool-result': + return toToolResultPart(part); + default: + return { type: 'text' as const, text: String(part) }; + } + }), + } as ModelMessage; + } + case 'system': + return { + role: 'system' as const, + content: message.content, + } as ModelMessage; + case 'tool': + return { + role: 'tool' as const, + content: (message.content as Record[]).map( + toToolResultPart + ), + } as ModelMessage; + case 'user': + return { + role: 'user' as const, + content: message.content, + } as ModelMessage; + default: + return { + role: 'user' as const, + content: String(message), + } as ModelMessage; + } + }); +} + +// ─── Individual scanners ──────────────────────────────────────────── + +/** + * Detect RuntimeSandboxState (top-level) or RuntimeSandboxSessionState + * (individual session record). + */ +const scanSandbox: Scanner = (value) => { + if (!value || typeof value !== 'object') return undefined; + const v = value as Record; + + // RuntimeSandboxState — { initializedSandboxNames: string[], sessions: ... } + if (Array.isArray(v.initializedSandboxNames) && Array.isArray(v.sessions)) { + return ['sandbox', value]; + } + + // RuntimeSandboxSessionState — { sandboxName, sessionKey, adapter } + if ( + typeof v.sandboxName === 'string' && + typeof v.sessionKey === 'string' && + typeof v.adapter === 'string' + ) { + return ['sandbox', value]; + } + + return undefined; +}; + +/** Is `msg` shaped like a ToolLoopTranscriptMessage? */ +function isToolLoopMessage(msg: unknown): boolean { + if (!msg || typeof msg !== 'object') return false; + const m = msg as Record; + if (!('role' in m) || !('content' in m)) return false; + + const role = m.role; + if (role === 'tool') { + return ( + Array.isArray(m.content) && + (m.content as Record[]).every( + (p) => p?.type === 'tool-result' + ) + ); + } + + return role === 'assistant' || role === 'system' || role === 'user'; +} + +/** + * Detect ToolLoopTranscriptMessage[] — distinguished from plain + * ModelMessage[] by the presence of tool-loop-specific features + * (tool messages, assistant content with tool-call/tool-result parts). + */ +const scanTranscriptMessages: Scanner = (value) => { + if (!Array.isArray(value) || value.length === 0) return undefined; + if (!value.every(isToolLoopMessage)) return undefined; + + const hasToolLoopFeatures = value.some((msg: Record) => { + if (msg.role === 'tool') return true; + if (msg.role === 'assistant' && Array.isArray(msg.content)) { + return (msg.content as Record[]).some( + (p) => p.type === 'tool-call' || p.type === 'tool-result' + ); + } + return false; + }); + + if (hasToolLoopFeatures) { + return ['model-messages', toModelMessages(value)]; + } + + return undefined; +}; + +/** + * Detect ModelMessage[] already in AI SDK format (role + content arrays + * with at least one user or assistant message). + */ +const scanModelMessages: Scanner = (value) => { + if (!Array.isArray(value) || value.length === 0) return undefined; + + const valid = value.every( + (msg) => msg && typeof msg === 'object' && 'role' in msg && 'content' in msg + ); + if (!valid) return undefined; + + const hasConversation = value.some( + (msg: Record) => + msg.role === 'user' || msg.role === 'assistant' + ); + if (!hasConversation) return undefined; + + return ['model-messages', value]; +}; + +/** + * Detect RuntimeActionRequest[] — objects with `callId` and a `kind` + * of "tool-call", "subagent-call", or "activate-skill". Converts to + * a synthetic assistant ModelMessage with tool-call parts. + */ +const scanActionRequests: Scanner = (value) => { + if (!Array.isArray(value) || value.length === 0) return undefined; + + const isRequests = value.every((req) => { + if (!req || typeof req !== 'object') return false; + const r = req as Record; + return ( + typeof r.callId === 'string' && + typeof r.kind === 'string' && + ['tool-call', 'subagent-call', 'activate-skill'].includes( + r.kind as string + ) + ); + }); + + if (!isRequests) return undefined; + + const parts = value.map((req: Record) => ({ + type: 'tool-call' as const, + toolCallId: req.callId as string, + toolName: + (req.toolName as string) ?? + (req.subagentName as string) ?? + 'activate-skill', + input: req.input ?? {}, + })); + + return ['model-messages', [{ role: 'assistant' as const, content: parts }]]; +}; + +// ─── Scanner registry ─────────────────────────────────────────────── + +const scanners: Scanner[] = [ + scanSandbox, + scanTranscriptMessages, + scanModelMessages, + scanActionRequests, +]; + +// ─── Recursive deep scan ──────────────────────────────────────────── + +const DEFAULT_MAX_DEPTH = 6; + +function deepScan( + value: unknown, + depth: number, + maxDepth: number, + results: [AgentScanResultType, unknown][] +): void { + if (depth > maxDepth) return; + if (value == null || typeof value !== 'object') return; + // Skip encrypted / display markers + if (isEncryptedMarker(value)) return; + + for (const scanner of scanners) { + const match = scanner(value, depth); + if (match) { + results.push(match); + return; // Don't recurse into matched subtree + } + } + + if (Array.isArray(value)) { + for (const item of value) { + deepScan(item, depth + 1, maxDepth, results); + } + } else { + for (const val of Object.values(value as Record)) { + deepScan(val, depth + 1, maxDepth, results); + } + } +} + +/** Scan a single value tree and return all matches. */ +export function scanValue( + value: unknown, + maxDepth = DEFAULT_MAX_DEPTH +): [AgentScanResultType, unknown][] { + const results: [AgentScanResultType, unknown][] = []; + deepScan(value, 0, maxDepth, results); + return results; +} + +// ─── Entity-level scanning ────────────────────────────────────────── + +/** Scan a single run or step's input/output fields. */ +export function scanEntity( + entity: { input?: unknown; output?: unknown }, + entityType: 'run' | 'step', + entityId: string +): AgentScanMatch[] { + const matches: AgentScanMatch[] = []; + for (const field of ['input', 'output'] as const) { + const fieldValue = (entity as Record)[field]; + if (fieldValue == null) continue; + for (const [type, value] of scanValue(fieldValue)) { + matches.push({ type, value, source: { entityType, entityId, field } }); + } + } + return matches; +} + +/** Scan the run and all provided steps, returning every match. */ +export function scanRunAndSteps( + run: WorkflowRun, + steps: Step[] +): AgentScanMatch[] { + const matches = scanEntity(run, 'run', run.runId); + for (const step of steps) { + matches.push(...scanEntity(step, 'step', step.stepId)); + } + return matches; +} + +/** Quick predicate — returns true if any agent data is detected. */ +export function hasAgentData(run: WorkflowRun, steps: Step[]): boolean { + // Check run first (fast path) + for (const field of ['input', 'output'] as const) { + const v = (run as Record)[field]; + if (v != null && scanValue(v).length > 0) return true; + } + for (const step of steps) { + for (const field of ['input', 'output'] as const) { + const v = (step as Record)[field]; + if (v != null && scanValue(v).length > 0) return true; + } + } + return false; +} diff --git a/packages/web/app/components/run-detail-view.tsx b/packages/web/app/components/run-detail-view.tsx index e7d8c11cd9..c0695c8183 100644 --- a/packages/web/app/components/run-detail-view.tsx +++ b/packages/web/app/components/run-detail-view.tsx @@ -1,6 +1,7 @@ import { parseWorkflowName } from '@workflow/utils/parse-name'; import type { SpanSelectionInfo } from '@workflow/web-shared'; import { + AgentView, DecryptButton, ErrorBoundary, EventListView, @@ -10,9 +11,10 @@ import { stepEventsToStepEntity, WorkflowTraceViewer, } from '@workflow/web-shared'; -import type { Event, WorkflowRun } from '@workflow/world'; +import type { Event, Step, WorkflowRun } from '@workflow/world'; import { AlertCircle, + Bot, GitBranch, HelpCircle, List, @@ -20,7 +22,7 @@ import { Lock, Unlock, } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'; @@ -49,13 +51,15 @@ import { TooltipContent, TooltipTrigger, } from '~/components/ui/tooltip'; +import { useEventsListData } from '~/lib/client/hooks/use-events-list-data'; import { mapRunToExecution } from '~/lib/flow-graph/graph-execution-mapper'; import { useWorkflowGraphManifest } from '~/lib/flow-graph/use-workflow-graph'; import { useStreamReader } from '~/lib/hooks/use-stream-reader'; - -import { fetchEvent, getEncryptionKeyForRun } from '~/lib/rpc-client'; - -import { useEventsListData } from '~/lib/client/hooks/use-events-list-data'; +import { + fetchEvent, + fetchStep, + getEncryptionKeyForRun, +} from '~/lib/rpc-client'; import type { EnvMap } from '~/lib/types'; import { cancelRun, @@ -203,7 +207,7 @@ interface RunDetailViewProps { selectedId?: string; } -type Tab = 'trace' | 'graph' | 'streams' | 'events'; +type Tab = 'trace' | 'graph' | 'streams' | 'events' | 'agent'; export function RunDetailView({ runId, @@ -364,6 +368,73 @@ export function RunDetailView({ enabled: activeTab === 'events', }); + // ── Agent tab: lazily fetch step data using IDs from trace events ── + const [agentSteps, setAgentSteps] = useState([]); + const [agentStepsLoading, setAgentStepsLoading] = useState(false); + const agentStepsFetched = useRef(false); + + // Extract unique step IDs from trace events (always available) + const stepIds = useMemo(() => { + const ids = new Set(); + for (const event of allEvents) { + if (event.eventType.startsWith('step_') && event.correlationId) { + ids.add(event.correlationId); + } + } + return Array.from(ids); + }, [allEvents]); + + useEffect(() => { + if ( + activeTab !== 'agent' || + agentStepsFetched.current || + stepIds.length === 0 + ) + return; + agentStepsFetched.current = true; + setAgentStepsLoading(true); + + (async () => { + try { + const hydrateWithKey = encryptionKeyRef.current + ? (r: Step) => hydrateResourceIOWithKey(r, encryptionKeyRef.current!) + : (r: Step) => Promise.resolve(hydrateResourceIO(r)); + + const resolved = ( + await Promise.all( + stepIds.map(async (stepId) => { + try { + const { result } = await unwrapServerActionResult( + fetchStep(env, runId, stepId, 'all') + ); + return result ? hydrateWithKey(result) : null; + } catch { + return null; + } + }) + ) + ).filter((s): s is Step => s !== null); + setAgentSteps(resolved); + } catch { + // Agent tab will show "no data" + } finally { + setAgentStepsLoading(false); + } + })(); + }, [activeTab, env, runId, stepIds]); + + // Re-hydrate agent steps when encryption key changes + useEffect(() => { + if (!encryptionKey || agentSteps.length === 0) return; + (async () => { + const rehydrated = await Promise.all( + agentSteps.map((step) => hydrateResourceIOWithKey(step, encryptionKey)) + ); + setAgentSteps(rehydrated); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [encryptionKey]); + const [spanSelection, setSpanSelection] = useState( null ); @@ -735,6 +806,10 @@ export function RunDetailView({ Streams + + + Agent + @@ -905,6 +980,32 @@ export function RunDetailView({ + + +
+ +
+
+
+ {/* Graph tab hidden for now */} {false && isLocalBackend && (