web: hide tool entries in Sybil Agent transcript

This commit is contained in:
2026-10-14 15:29:50 -07:00
parent 153aa5d2fa
commit 56279fba7f
4 changed files with 100 additions and 111 deletions
@@ -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;
@@ -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 (
<details className={`agent-room__tool${active ? " is-running" : ""}${failed ? " is-failed" : ""}`} data-agent-message-id={message.id}>
<summary>
<span className="agent-room__tool-icon"><Wrench size={14} aria-hidden="true" /></span>
<span className="agent-room__tool-title">{title}</span>
<span className="agent-room__tool-state">{active ? "Running" : failed ? "Failed" : "Done"}</span>
<ChevronDown className="agent-room__tool-chevron" size={14} aria-hidden="true" />
</summary>
<div className="agent-room__tool-body">
{info.args && typeof info.args === "object" ? <pre aria-label="Tool input">{JSON.stringify(info.args, null, 2)}</pre> : null}
{preview ? <pre aria-label={failed ? "Tool error" : "Tool result"}>{preview}</pre> : null}
{!info.args && !preview ? <p>{active ? "This tool is still running." : "No additional details."}</p> : null}
</div>
</details>
);
}
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 (
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
{message.role === "tool" ? <AgentToolCard message={message} /> : <AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />}
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />
</div>
);
});
+5
View File
@@ -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;
+88 -2
View File
@@ -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);