diff --git a/web/src/components/agent/agent-conversation.css b/web/src/components/agent/agent-conversation.css index 49bbcf9..22cede2 100644 --- a/web/src/components/agent/agent-conversation.css +++ b/web/src/components/agent/agent-conversation.css @@ -194,83 +194,6 @@ color:hsl(189 79% 75%); } -.agent-room__tool { - margin:0 0 0 35px; - max-width:calc(89% - 35px); - border:1px solid hsl(256 35% 47%/.23); - border-radius:11px; - background:hsl(246 32% 12%/.56); - color:hsl(255 26% 75%); - font-size:11px; -} - -.agent-room__tool summary { - display:flex; - align-items:center; - gap:9px; - min-height:37px; - padding:8px 11px; - cursor:pointer; - list-style:none; -} - -.agent-room__tool summary::-webkit-details-marker { - display:none; -} - -.agent-room__tool-icon { - display:grid; - place-items:center; - color:hsl(267 69% 76%); -} - -.agent-room__tool-title { - min-width:0; - flex:1; - overflow:hidden; - text-overflow:ellipsis; - white-space:nowrap; - text-transform:capitalize; -} - -.agent-room__tool-state { - color:hsl(254 18% 51%); - font-size:9px; -} - -.agent-room__tool.is-running .agent-room__tool-state { - color:hsl(184 54% 72%); -} - -.agent-room__tool.is-failed .agent-room__tool-state { - color:hsl(351 71% 76%); -} - -.agent-room__tool-chevron { - color:hsl(255 20% 52%); - transition:transform .15s; -} - -.agent-room__tool[open] .agent-room__tool-chevron { - transform:rotate(180deg); -} - -.agent-room__tool-body { - padding:0 11px 11px; -} - -.agent-room__tool-body pre { - max-height:210px; - overflow:auto; - white-space:pre-wrap; - overflow-wrap:anywhere; - font:10px/1.6 ui-monospace,monospace; - margin:8px 0 0; - padding:9px; - border-radius:6px; - background:hsl(239 42% 6%/.7); -} - .agent-room__typing,.agent-room__waiting { display:flex; align-items:center; @@ -503,10 +426,6 @@ height:23px; border-radius:8px; } - .agent-room__tool { - margin-left:30px; - max-width:calc(96% - 30px); - } .agent-room__typing,.agent-room__waiting { margin-left:30px; font-size:10px; diff --git a/web/src/components/agent/agent-conversation.tsx b/web/src/components/agent/agent-conversation.tsx index 06a3c8c..88fbc24 100644 --- a/web/src/components/agent/agent-conversation.tsx +++ b/web/src/components/agent/agent-conversation.tsx @@ -1,7 +1,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; -import { ArrowDown, ChevronDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff, Wrench } from "lucide-preact"; +import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact"; import type { Message } from "@/lib/api"; import type { AgentRoomHookResult } from "@/hooks/use-agent-room"; +import { agentTranscriptMessages } from "@/lib/agent-room-presentation"; import { MarkdownContent } from "@/components/markdown/markdown-content"; import { Button } from "@/components/ui/button"; import "./agent-conversation.css"; @@ -46,29 +47,6 @@ function toolName(value: unknown) { return typeof value === "string" ? value.replace(/_/g, " ") : "Tool"; } -function AgentToolCard({ message }: { message: Message }) { - const info = metadata(message); - const active = info.status === "initiated"; - const failed = info.status === "failed"; - const title = toolName(info.toolName || message.name); - const preview = typeof info.resultPreview === "string" ? info.resultPreview : typeof info.error === "string" ? info.error : ""; - return ( -
- - - {title} - {active ? "Running" : failed ? "Failed" : "Done"} - -
- {info.args && typeof info.args === "object" ?
{JSON.stringify(info.args, null, 2)}
: null} - {preview ?
{preview}
: null} - {!info.args && !preview ?

{active ? "This tool is still running." : "No additional details."}

: null} -
-
- ); -} - function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) { const own = message.role === "user"; const info = metadata(message); @@ -254,21 +232,22 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro }; const messages = useMemo(() => { + const transcript = agentTranscriptMessages(room.messages); let previousDay = ""; - for (const message of room.messages) { - if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading && message.role !== "tool") { + for (const message of transcript) { + if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) { enteringMessageIdsRef.current.add(message.id); } seenMessageIdsRef.current.add(message.id); } if (!room.isLoading) historyReadyRef.current = true; - return room.messages.filter(message => message.role !== "system").map(message => { + return transcript.map(message => { const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day; previousDay = day; return (
{separator ?
{dayLabel(date)}
: null} - {message.role === "tool" ? : } +
); }); diff --git a/web/src/lib/agent-room-presentation.ts b/web/src/lib/agent-room-presentation.ts index ecc052a..a07feee 100644 --- a/web/src/lib/agent-room-presentation.ts +++ b/web/src/lib/agent-room-presentation.ts @@ -1,6 +1,11 @@ import type { AgentRoomEvent, AgentRoomState } from './agent-room'; import type { Message } from './api'; +/** Select transcript rows only; keep tool entries in room state for replay and activity. */ +export function agentTranscriptMessages(messages: readonly Message[]): Message[] { + return messages.filter(message => message.role !== 'system' && message.role !== 'tool'); +} + type Scheduler = { now(): number; schedule(callback: () => void, delay: number): unknown; diff --git a/web/tests/agent-room-presentation.test.mjs b/web/tests/agent-room-presentation.test.mjs index a178af5..08994e4 100644 --- a/web/tests/agent-room-presentation.test.mjs +++ b/web/tests/agent-room-presentation.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyAgentEvent, applyAgentReadState, createAgentRoomState } from '../src/lib/agent-room.ts'; -import { createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts'; +import { agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentRoomState, parseAgentRoomEvent } from '../src/lib/agent-room.ts'; +import { agentTranscriptMessages, createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts'; const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role, content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra }); @@ -27,6 +27,92 @@ function fixture(initial = createAgentRoomState()) { return { presenter, advance, frames, last: () => frames.at(-1) }; } +test('loaded history hides tool calls and results without removing records or changing normal messages', () => { + const user = message(1, 'user', { content: 'Explain this tool_call and tool_result JSON.' }); + const reply = message(5, 'assistant', { content: '**Result**\n\nThe lookup failed; please try another source.' }); + const tools = ['initiated', 'completed', 'failed'].map((status, index) => message(index + 2, 'tool', { + id: `tool:1:lookup-${index}`, name: 'lookup', content: 'Raw tool details', + metadata: { kind: 'tool_call', entryId: index + 2, toolCallId: `lookup-${index}`, status, + args: { query: 'raw input' }, resultPreview: 'raw output', error: status === 'failed' ? 'raw error' : null }, + })); + const room = applyAgentPage(createAgentRoomState(), page(5, [message(0, 'system'), user, ...tools, reply])); + const original = structuredClone(room); + const transcript = agentTranscriptMessages(agentDisplayMessages(room)); + + assert.deepEqual(transcript, [user, reply]); + assert.strictEqual(transcript[0], user); + assert.strictEqual(transcript[1], reply); + assert.deepEqual(room, original, 'presentation must leave tool records and cursor intact'); + assert.equal(room.messages.length, 6); + assert.equal(agentUnreadCount(room), 1); +}); + +test('tool-only history produces no transcript rows regardless of metadata or status', () => { + const messages = [null, {}, [], 'legacy metadata', { kind: 'tool_result' }, { kind: 'tool_call', status: 'failed' }] + .map((metadata, index) => message(index + 1, 'tool', { metadata, createdAt: `2026-10-${10 + index}T00:00:00.000Z` })); + assert.deepEqual(agentTranscriptMessages(Object.freeze(messages)), []); + assert.deepEqual(agentTranscriptMessages([]), []); +}); + +test('user delivery errors, queued messages, and assistant content remain visible even with tool-like metadata', () => { + const users = ['sending', 'queued', 'failed', 'unconfirmed'].map((status, index) => message(index + 1, 'user', { + metadata: { kind: 'message', status, error: 'Could not send; retry this message.' }, + })); + const reply = message(5, 'assistant', { content: 'The tool_result contained an error. **Please retry.**', + metadata: { kind: 'tool_call', status: 'failed', error: 'Keep this assistant message.' }, + }); + const room = applyAgentEvent(applyAgentPage(createAgentRoomState(), page(5, [users[0], ...users.slice(2), reply])), + activity(false, { queued: 1, queuedMessages: [users[1]] })); + const transcript = agentTranscriptMessages(agentDisplayMessages(room)); + + assert.deepEqual(transcript, [...users, reply]); + for (const [index, original] of [...users, reply].entries()) assert.strictEqual(transcript[index], original); +}); + +for (const status of ['completed', 'failed']) { + test(`live ${status} tool updates and replay stay hidden while progress, replies, and cursors survive`, () => { + const f = fixture(); + const user = message(1, 'user'); + const started = message(2, 'tool', { id: 'tool:1:lookup', name: 'lookup', + metadata: { kind: 'tool_call', toolCallId: 'lookup', toolName: 'lookup', entryId: 2, status: 'initiated', args: { query: 'input' } }, + }); + const finished = { ...started, content: 'Raw tool output', + metadata: { kind: 'tool_call', toolCallId: 'lookup', entryId: 3, status, resultPreview: 'output', error: status === 'failed' ? 'Lookup failed' : null }, + }; + const progress = activity(true, { tools: [{ id: 'lookup', name: 'lookup', status: 'running' }], backgroundWorkers: 1 }); + const receive = event => f.presenter.push(parseAgentRoomEvent(JSON.stringify(event))); + f.presenter.push(page(1, [user]), { replay: true }); + receive(progress); f.advance(250); + + for (const event of [page(2, [started]), page(3, [finished]), page(2, [started])]) { + receive(event); f.advance(100); + const { room, isTyping } = f.last(); + assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(room)), [user]); + assert.equal(isTyping, true, 'tool visibility must not suppress ongoing progress'); + assert.deepEqual(room.activity, progress.activity); + assert.equal(room.messages.length, 2, 'tool records still merge by their stable ID'); + } + assert.equal(f.last().room.cursor, '3'); + assert.equal(f.last().room.readCursor, '0'); + assert.equal(agentUnreadCount(f.last().room), 0); + assert.equal(f.last().room.messages[1].metadata.status, status); + assert.deepEqual(f.last().room.messages[1].metadata.args, { query: 'input' }); + assert.equal(f.last().room.messages[1].metadata.resultPreview, 'output'); + + const reply = message(4, 'assistant', { content: status === 'failed' ? 'The lookup failed. Please try again.' : 'Here is the answer.' }); + receive(page(4, [reply])); f.advance(100); + assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(f.last().room)), [user, reply]); + assert.equal(f.last().isTyping, false, 'the answer still clears the old typing indicator'); + assert.equal(f.last().room.cursor, '4'); + assert.equal(agentUnreadCount(f.last().room), 1); + f.presenter.update(room => applyAgentReadState(room, { cursor: '4', updatedAt: null })); + f.presenter.flush(); + assert.equal(agentUnreadCount(f.last().room), 0); + assert.equal(f.last().room.messages.length, 3); + f.presenter.dispose(); + }); +} + test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => { const f = fixture(); f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);