Merge branch 'cw/improved-agent-outputs' into cw-merge-1772810637670
This commit is contained in:
407
apps/web/src/components/AgentOutputViewer.test.tsx
Normal file
407
apps/web/src/components/AgentOutputViewer.test.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import * as parseModule from '@/lib/parse-agent-output'
|
||||
import { AgentOutputViewer } from './AgentOutputViewer'
|
||||
|
||||
vi.mock('@/lib/trpc', () => ({
|
||||
trpc: {
|
||||
getAgentOutput: {
|
||||
useQuery: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
},
|
||||
onAgentOutput: {
|
||||
useSubscription: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks', () => ({
|
||||
useSubscriptionWithErrorHandling: vi.fn(() => ({
|
||||
error: null,
|
||||
isConnecting: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
function makeToolResultMessage(content: string) {
|
||||
return {
|
||||
type: 'tool_result' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSystemMessage(content: string) {
|
||||
return {
|
||||
type: 'system' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTextMessage(content: string) {
|
||||
return {
|
||||
type: 'text' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolCallMessage(content: string, toolName: string) {
|
||||
return {
|
||||
type: 'tool_call' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta: { toolName },
|
||||
}
|
||||
}
|
||||
|
||||
function makeTodoWriteMessage(todos: Array<{ content: string; status: string; activeForm: string }>) {
|
||||
return {
|
||||
type: 'tool_call' as const,
|
||||
content: 'TodoWrite(...)',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta: {
|
||||
toolName: 'TodoWrite',
|
||||
toolInput: { todos },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolResultMessageWithMeta(
|
||||
content: string,
|
||||
meta: { toolName?: string; toolInput?: unknown }
|
||||
) {
|
||||
return {
|
||||
type: 'tool_result' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
function makeErrorMessage(content: string) {
|
||||
return {
|
||||
type: 'error' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeSessionEndMessage(content: string) {
|
||||
return {
|
||||
type: 'session_end' as const,
|
||||
content,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentOutputViewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default: no messages
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([])
|
||||
})
|
||||
|
||||
// Test 1: tool_result renders collapsed by default
|
||||
it('renders tool_result collapsed by default', () => {
|
||||
const content = 'file content here and more stuff'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// ChevronRight should be present (collapsed state)
|
||||
// We check that the SVG for ChevronRight is in the document
|
||||
// lucide-react renders SVGs — we look for the collapsed container containing the preview text
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
|
||||
// "Result" badge should NOT be visible (collapsed)
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
|
||||
// The container should NOT show a "Result" badge
|
||||
// ChevronRight is rendered — verify no ChevronDown
|
||||
const svgs = document.querySelectorAll('svg')
|
||||
// We look for the collapsed state by absence of "Result" text
|
||||
expect(screen.queryByText('Result')).toBeNull()
|
||||
})
|
||||
|
||||
// Test 2: Clicking collapsed result expands it
|
||||
it('expands tool_result on click', () => {
|
||||
const longContent = 'a'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Initially collapsed — click it
|
||||
const collapsedContainer = screen.getByText(longContent.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(collapsedContainer)
|
||||
|
||||
// After click: "Result" badge should be visible
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Full content should be visible in whitespace-pre-wrap element
|
||||
const preWrap = document.querySelector('.whitespace-pre-wrap')
|
||||
expect(preWrap).toBeInTheDocument()
|
||||
expect(preWrap).toHaveTextContent(longContent)
|
||||
|
||||
// ChevronRight should no longer be visible; ChevronDown should be present
|
||||
expect(screen.queryByText('Result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 3: Clicking expanded result collapses it again
|
||||
it('collapses tool_result on second click', () => {
|
||||
const longContent = 'b'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(longContent)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Click once to expand
|
||||
const container = screen.getByText(longContent.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(container)
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Click again to collapse
|
||||
// After expansion, the container still exists — click the expanded container
|
||||
// The clickable container is the border-l-2 div
|
||||
const expandedContainer = screen.getByText('Result').closest('.border-l-2')!
|
||||
fireEvent.click(expandedContainer)
|
||||
|
||||
// Should be collapsed again
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(longContent.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 4: system message renders as single dim line, no badge, no border-l
|
||||
it('renders system message as a single dim line without badge or border', () => {
|
||||
const content = 'Session started: abc-123'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSystemMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Content is visible
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
|
||||
// No "System" badge text
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument()
|
||||
|
||||
// The rendered element should NOT have border-l class
|
||||
const el = screen.getByText(content)
|
||||
expect(el.className).not.toContain('border-l')
|
||||
expect(el.closest('[class*="border-l"]')).toBeNull()
|
||||
})
|
||||
|
||||
// Test 5: agentId prop change resets expanded results
|
||||
it('resets expanded results when agentId changes', () => {
|
||||
const content = 'c'.repeat(100)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolResultMessage(content)])
|
||||
|
||||
const { rerender } = render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Expand the result
|
||||
const collapsedContainer = screen.getByText(content.substring(0, 80)).closest('div')!
|
||||
fireEvent.click(collapsedContainer)
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
|
||||
// Change agentId — should reset expandedResults
|
||||
rerender(<AgentOutputViewer agentId="agent-2" />)
|
||||
|
||||
// After agentId change, result should be collapsed again
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
// Preview text should be visible (collapsed state)
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Test 6: Other message types remain always-expanded (unaffected)
|
||||
it('always renders text messages fully', () => {
|
||||
const content = 'This is a text message'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeTextMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
|
||||
// No chevron icons for text messages
|
||||
const svgCount = document.querySelectorAll('svg').length
|
||||
// Only the header bar icons (Pause) should be present, no expand/collapse chevrons
|
||||
expect(screen.queryByText('Result')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('System')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders tool_call messages fully', () => {
|
||||
const content = 'Read(file.txt)'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeToolCallMessage(content, 'Read')])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
// The tool name badge should be visible
|
||||
expect(screen.getByText('Read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders error messages with Error badge', () => {
|
||||
const content = 'Something went wrong'
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeErrorMessage(content)])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Error')).toBeInTheDocument()
|
||||
expect(screen.getByText(content)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always renders session_end messages with session completed text', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([makeSessionEndMessage('Session completed')])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Session completed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Todo strip', () => {
|
||||
it('is absent when no TodoWrite tool_call exists', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTextMessage('some output'),
|
||||
makeToolCallMessage('Read(file.txt)', 'Read'),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.queryByText('TASKS')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is present with TASKS label and all todos when a TodoWrite tool_call exists', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Fix bug', status: 'completed', activeForm: 'Fixing bug' },
|
||||
{ content: 'Add tests', status: 'in_progress', activeForm: 'Adding tests' },
|
||||
{ content: 'Update docs', status: 'pending', activeForm: 'Updating docs' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('TASKS')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fix bug')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add tests')).toBeInTheDocument()
|
||||
expect(screen.getByText('Update docs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the most recent TodoWrite todos', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Old task', status: 'pending', activeForm: 'Old task' },
|
||||
]),
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'New task', status: 'in_progress', activeForm: 'New task' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('New task')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Old task')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Loader2 with animate-spin for in_progress todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Work', status: 'in_progress', activeForm: 'Working' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Find SVG with animate-spin class (Loader2)
|
||||
const spinningIcon = document.querySelector('svg.animate-spin')
|
||||
expect(spinningIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders CheckCircle2 and strikethrough text for completed todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Done', status: 'completed', activeForm: 'Done' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
const doneText = screen.getByText('Done')
|
||||
expect(doneText.className).toContain('line-through')
|
||||
})
|
||||
|
||||
it('renders Circle and muted text without strikethrough for pending todo', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage([
|
||||
{ content: 'Later', status: 'pending', activeForm: 'Later' },
|
||||
]),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
const laterText = screen.getByText('Later')
|
||||
expect(laterText.className).not.toContain('line-through')
|
||||
})
|
||||
|
||||
it('renders at most 5 todo rows and shows overflow count when there are 7 todos', () => {
|
||||
const todos = Array.from({ length: 7 }, (_, i) => ({
|
||||
content: `Task ${i + 1}`,
|
||||
status: 'pending',
|
||||
activeForm: `Task ${i + 1}`,
|
||||
}))
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeTodoWriteMessage(todos),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
// Only first 5 are rendered
|
||||
expect(screen.getByText('Task 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Task 5')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Task 6')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Task 7')).not.toBeInTheDocument()
|
||||
|
||||
// Overflow indicator
|
||||
expect(screen.getByText('+ 2 more')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task result collapsed preview', () => {
|
||||
it('shows subagent_type result label when meta.toolName is Task with subagent_type', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), {
|
||||
toolName: 'Task',
|
||||
toolInput: { subagent_type: 'Explore', description: 'desc', prompt: 'prompt' },
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Explore result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Subagent result when meta.toolName is Task but no subagent_type', () => {
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta('raw subagent output content '.repeat(5), {
|
||||
toolName: 'Task',
|
||||
toolInput: { description: 'desc', prompt: 'prompt' },
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText('Subagent result')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows first 80 chars of content for non-Task tool results', () => {
|
||||
const content = 'some file content that is longer than 80 characters '.repeat(3)
|
||||
vi.spyOn(parseModule, 'parseAgentOutput').mockReturnValue([
|
||||
makeToolResultMessageWithMeta(content, {
|
||||
toolName: 'Read',
|
||||
}),
|
||||
])
|
||||
|
||||
render(<AgentOutputViewer agentId="agent-1" />)
|
||||
|
||||
expect(screen.getByText(content.substring(0, 80))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowDown, Pause, Play, AlertCircle, Square } from "lucide-react";
|
||||
import { ArrowDown, Pause, Play, AlertCircle, Square, ChevronRight, ChevronDown, CheckCircle2, Loader2, Circle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ interface AgentOutputViewerProps {
|
||||
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
|
||||
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Set<number>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
||||
const chunksRef = useRef<TimestampedChunk[]>([]);
|
||||
@@ -65,6 +66,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
chunksRef.current = [];
|
||||
setMessages([]);
|
||||
setFollow(true);
|
||||
setExpandedResults(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
// Auto-scroll to bottom when following
|
||||
@@ -95,6 +97,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
const isLoading = outputQuery.isLoading;
|
||||
const hasOutput = messages.length > 0;
|
||||
|
||||
const lastTodoWrite = [...messages]
|
||||
.reverse()
|
||||
.find(m => m.type === "tool_call" && m.meta?.toolName === "TodoWrite");
|
||||
|
||||
const currentTodos = lastTodoWrite
|
||||
? (lastTodoWrite.meta?.toolInput as { todos: Array<{ content: string; status: string; activeForm: string }> } | undefined)?.todos ?? []
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full rounded-lg border border-terminal-border overflow-hidden">
|
||||
{/* Header */}
|
||||
@@ -157,6 +167,30 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Todo strip */}
|
||||
{currentTodos.length > 0 && (
|
||||
<div className="bg-terminal border-b border-terminal-border px-4 py-2">
|
||||
<div className="text-[10px] text-terminal-muted/60 font-mono uppercase tracking-widest mb-1">TASKS</div>
|
||||
{currentTodos.slice(0, 5).map((todo, i) => (
|
||||
<div key={i} className="flex items-center gap-2 font-mono text-xs">
|
||||
{todo.status === "completed" && <CheckCircle2 className="h-3 w-3 text-terminal-muted/60" />}
|
||||
{todo.status === "in_progress" && <Loader2 className="h-3 w-3 text-terminal-fg animate-spin" />}
|
||||
{todo.status === "pending" && <Circle className="h-3 w-3 text-terminal-muted/40" />}
|
||||
<span className={
|
||||
todo.status === "completed"
|
||||
? "text-terminal-muted/60 line-through"
|
||||
: todo.status === "in_progress"
|
||||
? "text-terminal-fg"
|
||||
: "text-terminal-muted"
|
||||
}>{todo.content}</span>
|
||||
</div>
|
||||
))}
|
||||
{currentTodos.length > 5 && (
|
||||
<div className="font-mono text-xs text-terminal-muted/60">+ {currentTodos.length - 5} more</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output content */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -172,10 +206,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className={getMessageStyling(message.type)}>
|
||||
{message.type === 'system' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
||||
<span className="text-xs text-terminal-muted">{message.content}</span>
|
||||
<Timestamp date={message.timestamp} />
|
||||
<div className="text-terminal-muted/60 text-xs font-mono">
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -203,13 +235,34 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
)}
|
||||
|
||||
{message.type === 'tool_result' && (
|
||||
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
|
||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||
Result
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
<div
|
||||
className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0 cursor-pointer"
|
||||
onClick={() => setExpandedResults(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index); else next.add(index);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{expandedResults.has(index) ? (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
|
||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||
Result
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-terminal-muted inline-block shrink-0 mr-1" />
|
||||
<span className="text-terminal-muted/60 text-xs font-mono truncate">
|
||||
{message.meta?.toolName === "Task"
|
||||
? `${(message.meta.toolInput as any)?.subagent_type ?? "Subagent"} result`
|
||||
: message.content.substring(0, 80)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
264
apps/web/src/lib/parse-agent-output.test.ts
Normal file
264
apps/web/src/lib/parse-agent-output.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { parseAgentOutput } from "./parse-agent-output";
|
||||
|
||||
function chunk(events: object[]): string {
|
||||
return events.map((e) => JSON.stringify(e)).join("\n");
|
||||
}
|
||||
|
||||
describe("parseAgentOutput", () => {
|
||||
// 1. toolInput is set on tool_call messages
|
||||
it("sets meta.toolInput on tool_call messages", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Read",
|
||||
input: { file_path: "/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolCall = messages.find((m) => m.type === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall!.meta?.toolInput).toEqual({ file_path: "/foo.ts" });
|
||||
});
|
||||
|
||||
// 2. tool_result with tool_use_id gets meta.toolName and meta.toolInput from registry
|
||||
it("correlates tool_result to its tool_use via registry", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Read",
|
||||
input: { file_path: "/foo.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu1",
|
||||
content: "file contents",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBe("Read");
|
||||
expect(toolResult!.meta?.toolInput).toEqual({ file_path: "/foo.ts" });
|
||||
});
|
||||
|
||||
// 3. tool_result with no matching registry entry has no meta.toolName
|
||||
it("tool_result with unknown tool_use_id has no meta.toolName", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "unknown-id",
|
||||
content: "output",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBeUndefined();
|
||||
});
|
||||
|
||||
// 4. tool_result with is_error: true produces type: "error" and meta.isError: true
|
||||
it("tool_result with is_error: true produces error message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu1",
|
||||
is_error: true,
|
||||
content: "something went wrong",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const errorMsg = messages.find((m) => m.content === "something went wrong");
|
||||
expect(errorMsg).toBeDefined();
|
||||
expect(errorMsg!.type).toBe("error");
|
||||
expect(errorMsg!.meta?.isError).toBe(true);
|
||||
});
|
||||
|
||||
// 5. tool_result from a Task tool_use gets correct meta.toolName and meta.toolInput
|
||||
it("tool_result from Task tool_use has correct meta", () => {
|
||||
const taskInput = {
|
||||
subagent_type: "Explore",
|
||||
description: "find files",
|
||||
prompt: "search for *.ts",
|
||||
};
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu2",
|
||||
name: "Task",
|
||||
input: taskInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tu2",
|
||||
content: "found 10 files",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolResult = messages.find((m) => m.type === "tool_result");
|
||||
expect(toolResult).toBeDefined();
|
||||
expect(toolResult!.meta?.toolName).toBe("Task");
|
||||
expect(toolResult!.meta?.toolInput).toEqual(taskInput);
|
||||
});
|
||||
|
||||
// 6. Unknown top-level event type produces a system message
|
||||
it("unknown top-level event type produces system message", () => {
|
||||
const input = chunk([{ type: "future_event_type", data: {} }]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("[unknown event: future_event_type]");
|
||||
});
|
||||
|
||||
// 7. Unknown assistant content block type produces a system message
|
||||
it("unknown assistant content block type produces system message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "image", data: "base64..." }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("[unsupported content block: image]");
|
||||
});
|
||||
|
||||
// 8. Previously passing behavior unchanged
|
||||
describe("previously passing behavior", () => {
|
||||
it("system event with session_id produces system message", () => {
|
||||
const input = chunk([{ type: "system", session_id: "sess-123" }]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("system");
|
||||
expect(messages[0].content).toBe("Session started: sess-123");
|
||||
});
|
||||
|
||||
it("assistant text block produces text message", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("text");
|
||||
expect(messages[0].content).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("assistant tool_use block produces tool_call message with meta.toolName", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tu1",
|
||||
name: "Bash",
|
||||
input: { command: "ls -la", description: "list files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
const toolCall = messages.find((m) => m.type === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall!.meta?.toolName).toBe("Bash");
|
||||
});
|
||||
|
||||
it("result event with is_error: false produces session_end", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "result",
|
||||
is_error: false,
|
||||
total_cost_usd: 0.01,
|
||||
duration_ms: 5000,
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("session_end");
|
||||
expect(messages[0].content).toBe("Session completed");
|
||||
});
|
||||
|
||||
it("result event with is_error: true produces session_end with meta.isError", () => {
|
||||
const input = chunk([
|
||||
{
|
||||
type: "result",
|
||||
is_error: true,
|
||||
total_cost_usd: 0.01,
|
||||
duration_ms: 5000,
|
||||
},
|
||||
]);
|
||||
const messages = parseAgentOutput(input);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("session_end");
|
||||
expect(messages[0].meta?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("non-JSON line produces error message with raw line as content", () => {
|
||||
const rawLine = "This is not JSON at all";
|
||||
const messages = parseAgentOutput(rawLine);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].type).toBe("error");
|
||||
expect(messages[0].content).toBe(rawLine);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export interface ParsedMessage {
|
||||
timestamp?: Date;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
isError?: boolean;
|
||||
cost?: number;
|
||||
duration?: number;
|
||||
@@ -80,6 +81,7 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag
|
||||
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
|
||||
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
const toolUseRegistry = new Map<string, { name: string; input: unknown }>();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.content.split("\n").filter(Boolean);
|
||||
@@ -113,7 +115,14 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag
|
||||
type: "tool_call",
|
||||
content: formatToolCall(block),
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { toolName: block.name },
|
||||
meta: { toolName: block.name, toolInput: block.input },
|
||||
});
|
||||
toolUseRegistry.set(block.id, { name: block.name, input: block.input });
|
||||
} else {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `[unsupported content block: ${block.type}]`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,10 +158,20 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag
|
||||
output.length > 1000
|
||||
? output.substring(0, 1000) + "\n... (truncated)"
|
||||
: output;
|
||||
const isError = block.is_error === true;
|
||||
const originatingCall = block.tool_use_id
|
||||
? toolUseRegistry.get(block.tool_use_id)
|
||||
: undefined;
|
||||
parsedMessages.push({
|
||||
type: "tool_result",
|
||||
type: isError ? "error" : "tool_result",
|
||||
content: displayOutput,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: {
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(originatingCall
|
||||
? { toolName: originatingCall.name, toolInput: originatingCall.input }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -180,6 +199,12 @@ export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessag
|
||||
duration: event.duration_ms,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `[unknown event: ${event.type ?? "(no type)"}]`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
|
||||
Reference in New Issue
Block a user