mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(conversations): unify agent activity into one live rail (#5141)
This commit is contained in:
@@ -168,7 +168,15 @@ export function AgentProcessSourcePanel({
|
||||
)
|
||||
) : transcript.length > 0 ? (
|
||||
// Hermes-style interleaved narration + grouped, human-labeled steps.
|
||||
<ProcessingTranscriptView transcript={transcript} entries={entries} />
|
||||
// `renderSubagent` restores the nested child-run activity the
|
||||
// legacy fallback below always had — without it a delegated
|
||||
// sub-agent collapsed to a single line here, hiding every tool
|
||||
// call it made.
|
||||
<ProcessingTranscriptView
|
||||
transcript={transcript}
|
||||
entries={entries}
|
||||
renderSubagent={subagent => <SubagentActivityBlock subagent={subagent} />}
|
||||
/>
|
||||
) : entries.length > 0 ? (
|
||||
// Legacy snapshot (no transcript): fall back to the tool timeline,
|
||||
// which already nests each sub-agent's full activity inline.
|
||||
|
||||
@@ -26,6 +26,7 @@ import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
|
||||
import { ShareMessageButton } from '../../share/ShareMessageButton';
|
||||
import { buildThreadTimeline } from '../timeline/selectors';
|
||||
import { type AgentBubblePosition, formatRelativeTime } from '../utils/format';
|
||||
import { supersededInterimIndexes } from '../utils/interimNarration';
|
||||
import { AgentMessageBubble, AgentMessageText, BubbleMarkdown } from './AgentMessageBubble';
|
||||
import { AgentProcessSourcePanel } from './AgentProcessSourcePanel';
|
||||
import { BackgroundProcessesPanel, selectBackgroundProcesses } from './BackgroundProcessesPanel';
|
||||
@@ -265,7 +266,19 @@ export const ChatThreadView = forwardRef<ChatThreadViewHandle, ChatThreadViewPro
|
||||
const openSubagentEntry = openSubagentTaskId
|
||||
? selectedThreadToolTimeline.find(entry => entry.subagent?.taskId === openSubagentTaskId)
|
||||
: undefined;
|
||||
const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden);
|
||||
// Interim narration bubbles ("Let me get the data for both.", "The HTML is
|
||||
// hard to parse. Let me search for a clean table.") are live progress, not
|
||||
// content: once the turn delivers its real answer they are superseded, and
|
||||
// because they were never filtered they piled up permanently between the
|
||||
// question and the answer. Hidden once their own turn produced a final,
|
||||
// non-interim agent message — so a turn in flight keeps them visible, an
|
||||
// answered turn drops them, and a turn that died before answering keeps
|
||||
// them (they are its only record). They remain reachable via "View full
|
||||
// agent process Source"; the Flows copilot drops them outright.
|
||||
const supersededInterim = useMemo(() => supersededInterimIndexes(messages), [messages]);
|
||||
const visibleMessages = messages.filter(
|
||||
(msg, index) => !msg.extraMetadata?.hidden && !supersededInterim.has(index)
|
||||
);
|
||||
const hasVisibleMessages = visibleMessages.length > 0;
|
||||
const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null;
|
||||
const latestVisibleAgentMessage = [...visibleMessages]
|
||||
@@ -441,6 +454,11 @@ export const ChatThreadView = forwardRef<ChatThreadViewHandle, ChatThreadViewPro
|
||||
// retry. `isSending` already excludes it (only `'started'` /
|
||||
// `'streaming'`, same as this component's own live-turn checks).
|
||||
turnActive={isSending}
|
||||
// Interleaved narration + thinking + tool steps in stream order.
|
||||
// Renders through the same `ProcessingTranscriptView` the Agent
|
||||
// Process Source panel uses, so the rail IS a windowed view of the
|
||||
// panel rather than a second, divergent rendering of the turn.
|
||||
transcript={selectedThreadProcessing}
|
||||
/>
|
||||
) : (
|
||||
// Transcript-only turn: reasoning/narration was streamed but no tool
|
||||
@@ -954,37 +972,29 @@ export const ChatThreadView = forwardRef<ChatThreadViewHandle, ChatThreadViewPro
|
||||
in-flight response. Rendered as plain text (not Markdown) to
|
||||
avoid jitter from partially-parsed fences. The final bubble
|
||||
replaces this via addInferenceResponse on chat_done. */}
|
||||
{selectedStreamingAssistant &&
|
||||
(selectedStreamingAssistant.thinking.length > 0 ||
|
||||
selectedStreamingAssistant.content.length > 0) && (
|
||||
<div className="flex justify-start">
|
||||
<div className="relative w-fit max-w-[75%]">
|
||||
{selectedStreamingAssistant.thinking.length > 0 && (
|
||||
<details className="mb-1.5 bg-surface-subtle rounded-lg px-3 py-1.5 text-xs text-content-secondary open:bg-stone-100 dark:bg-surface-muted dark:open:bg-neutral-800">
|
||||
<summary className="cursor-pointer select-none flex items-center gap-1.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-primary-400 animate-pulse" />
|
||||
<span>{t('chat.thinking')}</span>
|
||||
</summary>
|
||||
<pre className="whitespace-pre-wrap break-words mt-1.5 font-sans text-[11px] text-content-muted">
|
||||
{selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{selectedStreamingAssistant.content.length > 0 && (
|
||||
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-surface-strong/80 dark:bg-surface-muted text-content">
|
||||
<p className="text-xs text-content-secondary font-mono whitespace-pre-wrap break-words leading-snug">
|
||||
{selectedStreamingAssistant.content.length >
|
||||
STREAMING_PREVIEW_CHARS && (
|
||||
<span className="text-content-faint">…</span>
|
||||
)}
|
||||
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
|
||||
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedStreamingAssistant && selectedStreamingAssistant.content.length > 0 && (
|
||||
<div className="flex justify-start">
|
||||
<div className="relative w-fit max-w-[75%]">
|
||||
{/* Reasoning is not rendered here — the rail above renders
|
||||
the same reasoning through `ProcessingTranscriptView`,
|
||||
interleaved with the narration and tool steps it
|
||||
happened between. A separate bubble would show it twice
|
||||
with two lifetimes. The in-flight ANSWER preview below
|
||||
stays: that is the terminal response streaming in. */}
|
||||
{selectedStreamingAssistant.content.length > 0 && (
|
||||
<div className="rounded-2xl rounded-bl-md px-3 py-1.5 bg-surface-strong/80 dark:bg-surface-muted text-content">
|
||||
<p className="text-xs text-content-secondary font-mono whitespace-pre-wrap break-words leading-snug">
|
||||
{selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && (
|
||||
<span className="text-content-faint">…</span>
|
||||
)}
|
||||
{selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)}
|
||||
<span className="inline-block w-1 h-3 ml-0.5 align-middle bg-primary-400 animate-pulse" />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Interrupted turn's partial answer (restore-fidelity fix 2):
|
||||
a settled, marked-interrupted bubble surfaced on restore. Only
|
||||
when NOT streaming live (the buffer is cleared by any live turn
|
||||
|
||||
@@ -27,9 +27,22 @@ import { ToolFailureLines } from './ToolFailureLines';
|
||||
export function ProcessingTranscriptView({
|
||||
transcript,
|
||||
entries,
|
||||
renderSubagent,
|
||||
}: {
|
||||
transcript: ProcessingTranscriptItem[];
|
||||
entries: ToolTimelineEntry[];
|
||||
/**
|
||||
* Renders a delegated sub-agent's nested activity (its own child tool calls,
|
||||
* transcript and thoughts) under the row that spawned it.
|
||||
*
|
||||
* Injected rather than imported because `SubagentActivityBlock` lives in
|
||||
* `ToolTimelineBlock`, which imports THIS component for the inline rail —
|
||||
* importing it back would be a cycle. Without this, a `subagent:*` row
|
||||
* rendered as a bare one-line step and every child tool call it made was
|
||||
* invisible, even though the entry carries them. Omit it and rows degrade to
|
||||
* that one-line form rather than breaking.
|
||||
*/
|
||||
renderSubagent?: (subagent: NonNullable<ToolTimelineEntry['subagent']>) => React.ReactNode;
|
||||
}) {
|
||||
const blocks = buildProcessingBlocks(transcript, entries);
|
||||
if (blocks.length === 0) return null;
|
||||
@@ -50,7 +63,14 @@ export function ProcessingTranscriptView({
|
||||
if (block.kind === 'thinking') {
|
||||
return <ThinkingBlock key={block.key} text={block.text} />;
|
||||
}
|
||||
return <ToolGroupBlock key={block.key} summary={block.summary} entries={block.entries} />;
|
||||
return (
|
||||
<ToolGroupBlock
|
||||
key={block.key}
|
||||
summary={block.summary}
|
||||
entries={block.entries}
|
||||
renderSubagent={renderSubagent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@@ -84,7 +104,15 @@ function ThinkingBlock({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
/** A collapsible group of consecutive tool rows under a human summary. */
|
||||
function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTimelineEntry[] }) {
|
||||
function ToolGroupBlock({
|
||||
summary,
|
||||
entries,
|
||||
renderSubagent,
|
||||
}: {
|
||||
summary: string;
|
||||
entries: ToolTimelineEntry[];
|
||||
renderSubagent?: (subagent: NonNullable<ToolTimelineEntry['subagent']>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const allSettled = entries.every(e => e.status !== 'running');
|
||||
const anyError = entries.some(e => e.status === 'error');
|
||||
@@ -98,7 +126,7 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi
|
||||
</summary>
|
||||
<ul className="mt-1 ml-1 space-y-1 border-l border-line pl-3">
|
||||
{entries.map(entry => (
|
||||
<ToolRow key={entry.id} entry={entry} />
|
||||
<ToolRow key={entry.id} entry={entry} renderSubagent={renderSubagent} />
|
||||
))}
|
||||
{allSettled ? (
|
||||
<li className="flex items-center gap-1.5 pt-0.5">
|
||||
@@ -114,24 +142,42 @@ function ToolGroupBlock({ summary, entries }: { summary: string; entries: ToolTi
|
||||
}
|
||||
|
||||
/** One tool step: type icon + human sentence + contextual detail chip. */
|
||||
function ToolRow({ entry }: { entry: ToolTimelineEntry }) {
|
||||
function ToolRow({
|
||||
entry,
|
||||
renderSubagent,
|
||||
}: {
|
||||
entry: ToolTimelineEntry;
|
||||
renderSubagent?: (subagent: NonNullable<ToolTimelineEntry['subagent']>) => React.ReactNode;
|
||||
}) {
|
||||
const { title, detail } = formatTimelineEntry(entry);
|
||||
return (
|
||||
<li className="flex items-start gap-1.5" data-testid="processing-tool-row">
|
||||
<span className="mt-0.5 shrink-0 text-content-faint">
|
||||
<CategoryIcon category={categorizeTool(entry.name)} />
|
||||
</span>
|
||||
<span className="min-w-0 text-[12px] text-content-secondary">
|
||||
{title}
|
||||
{detail ? (
|
||||
<span className="ml-1 rounded bg-surface-subtle px-1 py-px font-mono text-[10px] text-content-muted">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.status === 'error' && entry.failure ? (
|
||||
<ToolFailureLines failure={entry.failure} />
|
||||
) : null}
|
||||
</span>
|
||||
<li className="flex flex-col gap-1" data-testid="processing-tool-row">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 shrink-0 text-content-faint">
|
||||
<CategoryIcon category={categorizeTool(entry.name)} />
|
||||
</span>
|
||||
<span className="min-w-0 text-[12px] text-content-secondary">
|
||||
{title}
|
||||
{detail ? (
|
||||
<span className="ml-1 rounded bg-surface-subtle px-1 py-px font-mono text-[10px] text-content-muted">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
{entry.status === 'error' && entry.failure ? (
|
||||
<ToolFailureLines failure={entry.failure} />
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{/* A delegated sub-agent's own tool calls hang off the parent entry, so
|
||||
without this the whole child run collapsed into this single line.
|
||||
Rendered as a `<div>` SIBLING under the `<li>` (indented past the
|
||||
icon), not nested inside the label `<span>` — SubagentActivityBlock
|
||||
renders a `<div>`, and `<div>`-inside-`<span>` is invalid nesting. */}
|
||||
{entry.subagent && renderSubagent ? (
|
||||
<div className="ml-5" data-testid="processing-subagent">
|
||||
{renderSubagent(entry.subagent)}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import WorktreeActions from '../../../components/worktree/WorktreeActions';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type {
|
||||
ProcessingTranscriptItem,
|
||||
SubagentActivity,
|
||||
ToolFailureExplanation,
|
||||
ToolTimelineEntry,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
|
||||
import { BubbleMarkdown } from './AgentMessageBubble';
|
||||
import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail';
|
||||
import { ProcessingTranscriptView } from './ProcessingTranscriptView';
|
||||
import { ToolFailureLines } from './ToolFailureLines';
|
||||
import { WorkerThreadRefCard, type WorkerThreadStatus } from './WorkerThreadRefCard';
|
||||
|
||||
@@ -447,6 +449,18 @@ const BODY_SURFACE = 'bg-surface-muted';
|
||||
* collapsible "⚙️ Working… / Agentic task insights" header so the user can
|
||||
* fold the live activity away.
|
||||
*/
|
||||
/**
|
||||
* Height of the in-flight timeline viewport. While a turn is active the row
|
||||
* list is windowed to this height and auto-follows the newest activity, so a
|
||||
* long run (dozens of steps, each able to expand) can no longer grow without
|
||||
* bound and shove the composer + message list around mid-turn. Settled turns
|
||||
* are untouched — they still collapse to the group summary as before.
|
||||
*/
|
||||
const TIMELINE_VIEWPORT_CLASS = 'max-h-64 overflow-y-auto overscroll-contain';
|
||||
|
||||
/** Distance from the bottom (px) still treated as "pinned to the live edge". */
|
||||
const STICK_TO_BOTTOM_SLACK_PX = 24;
|
||||
|
||||
export function ToolTimelineBlock({
|
||||
entries,
|
||||
onViewSubagent,
|
||||
@@ -455,6 +469,7 @@ export function ToolTimelineBlock({
|
||||
expandAllRows = false,
|
||||
liveResponse,
|
||||
turnActive,
|
||||
transcript,
|
||||
}: {
|
||||
entries: ToolTimelineEntry[];
|
||||
/** Opens the full-transcript drawer for a subagent row. When omitted,
|
||||
@@ -490,6 +505,14 @@ export function ToolTimelineBlock({
|
||||
* omitted, which is correct for a settled/past-turn render (there is no
|
||||
* turn left to track). */
|
||||
turnActive?: boolean;
|
||||
/** The turn's interleaved processing transcript (narration + thinking + tool
|
||||
* pointers, in stream order). When non-empty the rail renders it through
|
||||
* {@link ProcessingTranscriptView} — the SAME component the Agent Process
|
||||
* Source panel uses — so the agent's prose and its tool steps appear in one
|
||||
* surface instead of narration living in the chat stream and thinking in a
|
||||
* separate bubble. Omitted/empty falls back to the tool-row list, which is
|
||||
* still correct for legacy snapshots that predate the transcript. */
|
||||
transcript?: ProcessingTranscriptItem[];
|
||||
}) {
|
||||
const { t } = useT();
|
||||
|
||||
@@ -550,7 +573,82 @@ export function ToolTimelineBlock({
|
||||
setPrevSettleSignal(settleSignal);
|
||||
}
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
// ── In-flight viewport: fixed height + auto-follow ──────────────────────
|
||||
// Windowed ONLY while the turn is in flight, and never under
|
||||
// `expandAllRows` (the Agent Process Source panel wants the full list, not
|
||||
// a 16rem porthole).
|
||||
//
|
||||
// Keyed off `turnActive` directly rather than `settleSignal` — the latter
|
||||
// falls back to `isRunning`, which flips once per SUB-AGENT inside a single
|
||||
// turn and would re-window + re-pin the viewport repeatedly mid-turn (the
|
||||
// same failure class as the #5008 collapse flicker). Callers that pass no
|
||||
// `turnActive` (settled/past-turn renders) never window at all, so nothing
|
||||
// about historical turns changes.
|
||||
const windowed = turnActive === true && !expandAllRows;
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
// Whether to keep pinning to the newest activity. A plain ref, not state:
|
||||
// no render output depends on it, and mutating it from the scroll handler
|
||||
// must not trigger a re-render on every wheel tick.
|
||||
const followTailRef = useRef(true);
|
||||
|
||||
// Re-pin whenever a new turn starts windowing, so a user who scrolled up
|
||||
// during the previous turn isn't stuck detached for the next one.
|
||||
useEffect(() => {
|
||||
if (windowed) followTailRef.current = true;
|
||||
}, [windowed]);
|
||||
|
||||
// Detach on scroll-up so reading an earlier step doesn't get yanked back
|
||||
// down by the next tool event; re-attach when they return to the bottom.
|
||||
const handleViewportScroll = () => {
|
||||
const el = viewportRef.current;
|
||||
if (!el) return;
|
||||
followTailRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_TO_BOTTOM_SLACK_PX;
|
||||
};
|
||||
|
||||
// Follow the live edge. A ResizeObserver on the row list (rather than an
|
||||
// effect keyed on row count) catches every way the content grows: a new row
|
||||
// arriving, the running row auto-expanding, and `tool_args_delta` streaming
|
||||
// into an already-expanded row — the last of which changes no React key at
|
||||
// all and would otherwise silently stop following mid-tool.
|
||||
//
|
||||
// Attached via a CALLBACK REF, not a `useEffect([windowed])`. The effect
|
||||
// version never attached in a real turn: `windowed` flips true at the START
|
||||
// of the turn, when there is nothing to show yet, so the component returned
|
||||
// null, `viewportRef.current` was null, and the effect bailed. Rows arriving
|
||||
// afterwards re-rendered the viewport but did not change `windowed`, so the
|
||||
// effect never re-ran and no observer was ever created. A callback ref fires
|
||||
// whenever the node itself mounts or changes, which is exactly the event we
|
||||
// care about, and is immune to that ordering entirely.
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const windowedRef = useRef(windowed);
|
||||
windowedRef.current = windowed;
|
||||
const attachViewport = useCallback((node: HTMLDivElement | null) => {
|
||||
observerRef.current?.disconnect();
|
||||
observerRef.current = null;
|
||||
viewportRef.current = node;
|
||||
if (!node || typeof ResizeObserver === 'undefined') return;
|
||||
// Children are in the DOM by the time a parent's ref callback runs.
|
||||
const inner = node.firstElementChild;
|
||||
if (!inner) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
// Read the live values through refs so the observer survives a settle →
|
||||
// re-arm without being torn down and rebuilt.
|
||||
if (!windowedRef.current || !followTailRef.current) return;
|
||||
// `auto`, not `smooth`: streaming deltas fire these back-to-back, and
|
||||
// queued smooth scrolls visibly lag behind the content.
|
||||
node.scrollTop = node.scrollHeight;
|
||||
});
|
||||
observer.observe(inner);
|
||||
observerRef.current = observer;
|
||||
}, []);
|
||||
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||||
|
||||
// Render whenever there is EITHER a tool row or transcript prose. Gating on
|
||||
// `entries` alone blanked the rail for the opening stretch of every turn —
|
||||
// narration streams before the first tool call — and hid a tool-less turn
|
||||
// (pure reasoning/narration) completely.
|
||||
if (entries.length === 0 && !(transcript && transcript.length > 0)) return null;
|
||||
|
||||
// The rows + the parent's streaming response — shared by both the collapsible
|
||||
// (in-flight) and static (settled) header layouts below.
|
||||
@@ -594,115 +692,153 @@ export function ToolTimelineBlock({
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="text-sm text-content-faint">
|
||||
{rows.map(({ entry, count }, index) => {
|
||||
const formatted = formatTimelineEntry(entry);
|
||||
const detailContent =
|
||||
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
|
||||
const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail);
|
||||
const subagent = entry.subagent;
|
||||
const resultContent = normalizeToolBody(entry.result);
|
||||
// A subagent row should always render the expandable details so
|
||||
// its live activity is visible — even when there is no prompt
|
||||
// detail to show. Mirrors the rule that a non-subagent row only
|
||||
// expands when it has detail content (or a result to show).
|
||||
const expandable = detailContent != null || subagent != null || resultContent != null;
|
||||
const isLatestRunning = latestRunningEntryId != null && latestRunningEntryId === entry.id;
|
||||
const shouldAutoExpand = expandAllRows || isLatestRunning;
|
||||
const nameTone = agentNameTone(entry.status);
|
||||
// Chat mode: the currently-running step stays expanded inline in the
|
||||
// main UI; finished steps collapse to a compact "View details →" link
|
||||
// (their full activity lives in the side panel).
|
||||
const compact = onViewDetails != null && !isLatestRunning;
|
||||
{/* Viewport wrapper. Stays in the tree in both modes so the row list is
|
||||
never remounted (and its <details> state never reset) when a turn
|
||||
settles — only the height/scroll classes toggle. */}
|
||||
<div
|
||||
ref={attachViewport}
|
||||
onScroll={windowed ? handleViewportScroll : undefined}
|
||||
data-testid="tool-timeline-viewport"
|
||||
data-windowed={windowed ? 'true' : 'false'}
|
||||
className={windowed ? TIMELINE_VIEWPORT_CLASS : undefined}>
|
||||
{transcript && transcript.length > 0 ? (
|
||||
<ProcessingTranscriptView
|
||||
transcript={transcript}
|
||||
entries={ordered}
|
||||
renderSubagent={subagent => (
|
||||
<SubagentActivityBlock
|
||||
subagent={subagent}
|
||||
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-content-faint">
|
||||
{rows.map(({ entry, count }, index) => {
|
||||
const formatted = formatTimelineEntry(entry);
|
||||
const detailContent =
|
||||
normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer);
|
||||
const workerRef = parseWorkerThreadRef(formatted.detail ?? entry.detail);
|
||||
const subagent = entry.subagent;
|
||||
const resultContent = normalizeToolBody(entry.result);
|
||||
// A subagent row should always render the expandable details so
|
||||
// its live activity is visible — even when there is no prompt
|
||||
// detail to show. Mirrors the rule that a non-subagent row only
|
||||
// expands when it has detail content (or a result to show).
|
||||
const expandable = detailContent != null || subagent != null || resultContent != null;
|
||||
const isLatestRunning =
|
||||
latestRunningEntryId != null && latestRunningEntryId === entry.id;
|
||||
const shouldAutoExpand = expandAllRows || isLatestRunning;
|
||||
const nameTone = agentNameTone(entry.status);
|
||||
// Chat mode: the currently-running step stays expanded inline in the
|
||||
// main UI; finished steps collapse to a compact "View details →" link
|
||||
// (their full activity lives in the side panel).
|
||||
const compact = onViewDetails != null && !isLatestRunning;
|
||||
|
||||
return (
|
||||
<AgentTimelineRail
|
||||
key={entry.id}
|
||||
isFirst={index === 0}
|
||||
isLast={index === rows.length - 1}>
|
||||
{compact ? (
|
||||
// Collapsed step: the whole label is the link — "Run Code →"
|
||||
// opens the full-run panel scoped to this step. A collapsed row
|
||||
// is backgrounded, so it never pulses — only the single active
|
||||
// (expanded) step blinks. Strip `animate-pulse` from the tone.
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewDetails(entry)}
|
||||
data-testid="view-details"
|
||||
className="group/details flex items-center gap-1.5 text-left">
|
||||
<span
|
||||
className={`text-[13px] font-medium ${nameTone.replace('animate-pulse ', '')} group-hover/details:underline`}>
|
||||
{formatted.title}
|
||||
</span>
|
||||
<RepeatCount count={count} />
|
||||
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
{resultContent ? (
|
||||
<pre
|
||||
data-testid="tool-result-output"
|
||||
className={`max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{resultContent}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : expandable ? (
|
||||
<details open={shouldAutoExpand} className="group/row">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
|
||||
<span className={`text-[13px] font-medium ${nameTone}`}>{formatted.title}</span>
|
||||
<span className="text-[11px] text-content-faint transition-transform group-open/row:rotate-90 dark:text-neutral-600">
|
||||
▶
|
||||
</span>
|
||||
</summary>
|
||||
{workerRef ? (
|
||||
<div
|
||||
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
|
||||
{workerRef.before}
|
||||
<WorkerThreadRefCard
|
||||
ref={workerRef.ref}
|
||||
status={workerStatusFromEntry(entry.status)}
|
||||
/>
|
||||
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
|
||||
return (
|
||||
<AgentTimelineRail
|
||||
key={entry.id}
|
||||
isFirst={index === 0}
|
||||
isLast={index === rows.length - 1}>
|
||||
{compact ? (
|
||||
// Collapsed step: the whole label is the link — "Run Code →"
|
||||
// opens the full-run panel scoped to this step. A collapsed row
|
||||
// is backgrounded, so it never pulses — only the single active
|
||||
// (expanded) step blinks. Strip `animate-pulse` from the tone.
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewDetails(entry)}
|
||||
data-testid="view-details"
|
||||
className="group/details flex items-center gap-1.5 text-left">
|
||||
<span
|
||||
className={`text-[13px] font-medium ${nameTone.replace('animate-pulse ', '')} group-hover/details:underline`}>
|
||||
{formatted.title}
|
||||
</span>
|
||||
<RepeatCount count={count} />
|
||||
<span className="text-[13px] font-medium text-primary-600 dark:text-primary-300">
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
{/* Output stays inline for FAILED steps only. On a success
|
||||
the agent's final answer is already the compression of
|
||||
what the tool returned, so repeating the raw result here
|
||||
just duplicates it — and a multi-tool turn stacked a
|
||||
scrollable <pre> per step above the answer. A failure is
|
||||
the case where the answer is least trustworthy (or may
|
||||
not mention the failure at all), so the evidence earns
|
||||
its space. Successful output is still one click away via
|
||||
this row's "→" and "View full agent process Source", and
|
||||
expanded rows / the process panel are unchanged. */}
|
||||
{resultContent && entry.status === 'error' ? (
|
||||
<pre
|
||||
data-testid="tool-result-output"
|
||||
className={`max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{resultContent}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : formatted.detail ? (
|
||||
<div
|
||||
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
|
||||
{formatted.detail}
|
||||
) : expandable ? (
|
||||
<details open={shouldAutoExpand} className="group/row">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 select-none marker:hidden">
|
||||
<span className={`text-[13px] font-medium ${nameTone}`}>
|
||||
{formatted.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-content-faint transition-transform group-open/row:rotate-90 dark:text-neutral-600">
|
||||
▶
|
||||
</span>
|
||||
</summary>
|
||||
{workerRef ? (
|
||||
<div
|
||||
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
|
||||
{workerRef.before}
|
||||
<WorkerThreadRefCard
|
||||
ref={workerRef.ref}
|
||||
status={workerStatusFromEntry(entry.status)}
|
||||
/>
|
||||
{workerRef.after ? <div className="mt-1">{workerRef.after}</div> : null}
|
||||
</div>
|
||||
) : formatted.detail ? (
|
||||
<div
|
||||
className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-content-secondary ${BODY_SURFACE}`}>
|
||||
{formatted.detail}
|
||||
</div>
|
||||
) : detailContent ? (
|
||||
<pre
|
||||
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{detailContent}
|
||||
</pre>
|
||||
) : null}
|
||||
{resultContent ? (
|
||||
// What the tool returned (size-capped upstream). Scrolls
|
||||
// inside its own box so a long result never floods the
|
||||
// timeline.
|
||||
<pre
|
||||
data-testid="tool-result-output"
|
||||
className={`mt-1 max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{resultContent}
|
||||
</pre>
|
||||
) : null}
|
||||
{subagent ? (
|
||||
<SubagentActivityBlock
|
||||
subagent={subagent}
|
||||
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[13px] font-medium ${nameTone}`}>
|
||||
{formatted.title}
|
||||
</span>
|
||||
<RepeatCount count={count} />
|
||||
</div>
|
||||
) : detailContent ? (
|
||||
<pre
|
||||
className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{detailContent}
|
||||
</pre>
|
||||
) : null}
|
||||
{resultContent ? (
|
||||
// What the tool returned (size-capped upstream). Scrolls
|
||||
// inside its own box so a long result never floods the
|
||||
// timeline.
|
||||
<pre
|
||||
data-testid="tool-result-output"
|
||||
className={`mt-1 max-h-40 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-content-secondary ${BODY_SURFACE}`}>
|
||||
{resultContent}
|
||||
</pre>
|
||||
) : null}
|
||||
{subagent ? (
|
||||
<SubagentActivityBlock
|
||||
subagent={subagent}
|
||||
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</details>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-[13px] font-medium ${nameTone}`}>{formatted.title}</span>
|
||||
<RepeatCount count={count} />
|
||||
</div>
|
||||
)}
|
||||
</AgentTimelineRail>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</AgentTimelineRail>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{liveResponse ? <LiveResponseBlock text={liveResponse} /> : null}
|
||||
</>
|
||||
@@ -719,7 +855,18 @@ export function ToolTimelineBlock({
|
||||
// otherwise a new turn streaming onto an already-mounted block (settling,
|
||||
// or starting a fresh run) would silently flip `open` out from under the
|
||||
// user's manual choice on every turn.
|
||||
const autoOpen = isRunning || expandAllRows;
|
||||
//
|
||||
// Driven by `settleSignal` (i.e. `turnActive` when the caller supplies it),
|
||||
// NOT by `isRunning`. `isRunning` means "a tool is executing *this instant*",
|
||||
// which goes false in every gap BETWEEN tools — while the agent reasons about
|
||||
// a result before issuing the next call. Keyed off that, the group snapped
|
||||
// shut a beat after each tool result and reopened when the next call started,
|
||||
// so a multi-tool turn flickered and a just-delivered result looked like it
|
||||
// had been wiped. #5008 already established `turnActive` as the correct
|
||||
// whole-turn signal and applied it to the override reset above; `autoOpen`
|
||||
// was left behind on `isRunning`. Same signal now drives both, so the group
|
||||
// stays open for the WHOLE turn and collapses once, at settle.
|
||||
const autoOpen = settleSignal || expandAllRows;
|
||||
const open = userOverrideOpen ?? autoOpen;
|
||||
|
||||
// Fully own the disclosure via React state rather than letting the browser
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
@@ -603,10 +603,14 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
|
||||
// Sub-agent A settles: `isRunning` flips true→false, but the whole
|
||||
// TURN is still active. Pre-fix this alone would have reset the
|
||||
// (nonexistent, still null) override — here there's nothing to reset,
|
||||
// but the auto rule alone already collapses it (autoOpen tracks
|
||||
// `isRunning`, unchanged by this fix).
|
||||
// TURN is still active, so the group STAYS OPEN.
|
||||
//
|
||||
// This expectation was inverted deliberately. #5008 moved the override
|
||||
// reset onto `turnActive` but left `autoOpen` on `isRunning`, so the
|
||||
// group still auto-collapsed in every gap between tools/sub-agents —
|
||||
// a just-delivered tool result appeared to be wiped a beat later, and a
|
||||
// multi-tool turn flickered. `autoOpen` now tracks the same whole-turn
|
||||
// signal as the reset, so the group collapses exactly once, at settle.
|
||||
const subagentASettled: ToolTimelineEntry[] = [
|
||||
{ id: 'a', name: 'subagent:researcher', round: 1, seq: 0, status: 'success' },
|
||||
];
|
||||
@@ -615,14 +619,19 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
|
||||
<ToolTimelineBlock entries={subagentASettled} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
|
||||
|
||||
// The user manually expands it while the turn is still in flight.
|
||||
fireEvent.click(screen.getByText('Agentic task insights'));
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
|
||||
// The user manually COLLAPSES it while the turn is still in flight.
|
||||
// (Pre-change the auto rule had already closed it here, so this click
|
||||
// was an expand; the override mechanic under test is identical either
|
||||
// way — what matters is that the explicit choice survives the toggles
|
||||
// below.)
|
||||
fireEvent.click(screen.getByText('Agentic task insights'));
|
||||
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
|
||||
|
||||
// Sub-agent B spawns: `isRunning` flips false→true again. Still one
|
||||
// turn (`turnActive` unchanged) — the user's override must hold.
|
||||
// turn (`turnActive` unchanged) — the user's override must hold, so the
|
||||
// group stays COLLAPSED despite the auto rule wanting it open.
|
||||
const subagentBRunning: ToolTimelineEntry[] = [
|
||||
...subagentASettled,
|
||||
{ id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'running' },
|
||||
@@ -632,12 +641,12 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
|
||||
<ToolTimelineBlock entries={subagentBRunning} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
|
||||
|
||||
// Sub-agent B settles: `isRunning` flips true→false a second time
|
||||
// within the SAME turn. This is exactly the edge that used to reset
|
||||
// the override and cause the flicker (#5008 regression) — with
|
||||
// `turnActive` supplied it must NOT reset; the user's expand sticks.
|
||||
// `turnActive` supplied it must NOT reset; the user's collapse sticks.
|
||||
const subagentBSettled: ToolTimelineEntry[] = [
|
||||
...subagentASettled,
|
||||
{ id: 'b', name: 'subagent:coder', round: 1, seq: 1, status: 'success' },
|
||||
@@ -647,7 +656,7 @@ describe('ToolTimelineBlock — agentic task insights surface', () => {
|
||||
<ToolTimelineBlock entries={subagentBSettled} turnActive />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
|
||||
|
||||
// Only when the TURN itself ends (`turnActive` true→false) does the
|
||||
// override reset — the panel then auto-collapses since the run is done.
|
||||
@@ -981,9 +990,11 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
|
||||
// (its activity is visible) — and shows no "View details" link itself.
|
||||
const activity = screen.getByTestId('subagent-activity');
|
||||
expect(activity.textContent).toContain('pondering');
|
||||
expect(screen.getByTestId('tool-result-output').textContent).toContain(
|
||||
'Prepared context from 3 sources.'
|
||||
);
|
||||
// The finished step SUCCEEDED, so its raw output is no longer duplicated
|
||||
// inline — the final answer already compresses it, and it stays reachable
|
||||
// through this row's "→". (Previously asserted present; see the
|
||||
// failure-only rule in the compact branch of ToolTimelineBlock.)
|
||||
expect(screen.queryByTestId('tool-result-output')).toBeNull();
|
||||
|
||||
// Clicking the finished step's link opens the full-run panel.
|
||||
fireEvent.click(links[0]);
|
||||
@@ -1024,3 +1035,556 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
|
||||
expect(screen.queryByTestId('view-details')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The in-flight viewport: while a turn is active the row list is windowed to
|
||||
// a fixed height and auto-follows the newest activity, so a long run can't
|
||||
// grow without bound and shove the composer around mid-turn. Settled turns
|
||||
// keep their previous full-height behaviour.
|
||||
describe('ToolTimelineBlock — in-flight viewport windowing', () => {
|
||||
const runningEntries: ToolTimelineEntry[] = [
|
||||
{ id: 'w-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' },
|
||||
{ id: 'w-2', name: 'code_executor', round: 1, seq: 1, status: 'running', detail: 'run' },
|
||||
];
|
||||
|
||||
it('windows the row list while the turn is active', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
expect(viewport.getAttribute('data-windowed')).toBe('true');
|
||||
expect(viewport.className).toContain('overflow-y-auto');
|
||||
});
|
||||
|
||||
it('does not window once the turn has settled', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive={false} />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
expect(viewport.getAttribute('data-windowed')).toBe('false');
|
||||
expect(viewport.className).not.toContain('overflow-y-auto');
|
||||
});
|
||||
|
||||
// Callers with no turn lifecycle to hand (settled / past-turn renders) must
|
||||
// be completely unaffected — windowing is opt-in via `turnActive`.
|
||||
it('does not window when the caller passes no turnActive', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={runningEntries} />);
|
||||
expect(screen.getByTestId('tool-timeline-viewport').getAttribute('data-windowed')).toBe(
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
// The Agent Process Source panel wants the whole list, not a porthole.
|
||||
it('never windows under expandAllRows, even mid-turn', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive expandAllRows />);
|
||||
expect(screen.getByTestId('tool-timeline-viewport').getAttribute('data-windowed')).toBe(
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
// Scrolling up detaches the auto-follow so reading an earlier step isn't
|
||||
// interrupted; returning to the bottom re-attaches it.
|
||||
it('detaches and re-attaches tail-following as the user scrolls', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
// jsdom reports 0 for all layout metrics, so drive them explicitly.
|
||||
Object.defineProperty(viewport, 'scrollHeight', { value: 500, configurable: true });
|
||||
Object.defineProperty(viewport, 'clientHeight', { value: 100, configurable: true });
|
||||
|
||||
viewport.scrollTop = 0; // scrolled to the top — detached
|
||||
expect(() => fireEvent.scroll(viewport)).not.toThrow();
|
||||
|
||||
viewport.scrollTop = 400; // back at the bottom — re-attached
|
||||
expect(() => fireEvent.scroll(viewport)).not.toThrow();
|
||||
});
|
||||
|
||||
// The row list must not remount when a turn settles, or every <details>
|
||||
// the user opened mid-turn would snap shut.
|
||||
it('keeps the row list mounted across the settle transition', () => {
|
||||
const { rerender } = renderInStore(<ToolTimelineBlock entries={runningEntries} turnActive />);
|
||||
const before = screen.getByTestId('tool-timeline-viewport').firstElementChild;
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock entries={runningEntries} turnActive={false} />
|
||||
</Provider>
|
||||
);
|
||||
const after = screen.getByTestId('tool-timeline-viewport').firstElementChild;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: the group used to auto-collapse in the GAP BETWEEN tools —
|
||||
// `autoOpen` keyed off `isRunning` ("a tool is executing right now"), which
|
||||
// goes false while the agent reasons about a result before issuing the next
|
||||
// call. A just-delivered tool result appeared to be wiped a beat later, and a
|
||||
// multi-tool turn flickered open/closed. The whole-turn signal (`turnActive`)
|
||||
// now drives it, so the group collapses exactly once, at settle.
|
||||
describe('ToolTimelineBlock — stays open between tools within a turn', () => {
|
||||
const settledRows: ToolTimelineEntry[] = [
|
||||
{ id: 'g-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' },
|
||||
{
|
||||
id: 'g-2',
|
||||
name: 'code_executor',
|
||||
round: 1,
|
||||
seq: 1,
|
||||
status: 'success',
|
||||
detail: 'run',
|
||||
result: 'exit 0',
|
||||
},
|
||||
];
|
||||
|
||||
it('stays open between tool calls while the turn is still active', () => {
|
||||
// No entry is `running` — the agent is reasoning before its next call.
|
||||
renderInStore(<ToolTimelineBlock entries={settledRows} turnActive />);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true);
|
||||
});
|
||||
|
||||
it('collapses once the turn itself settles', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={settledRows} turnActive={false} />);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', false);
|
||||
});
|
||||
|
||||
// Callers with no turn lifecycle fall back to `isRunning`, unchanged.
|
||||
it('falls back to isRunning when the caller passes no turnActive', () => {
|
||||
const running: ToolTimelineEntry[] = [
|
||||
{ id: 'g-3', name: 'code_executor', round: 1, seq: 0, status: 'running' },
|
||||
];
|
||||
renderInStore(<ToolTimelineBlock entries={running} />);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveProperty('open', true);
|
||||
renderInStore(<ToolTimelineBlock entries={settledRows} />);
|
||||
expect(screen.getAllByTestId('agent-task-insights')[1]).toHaveProperty('open', false);
|
||||
});
|
||||
|
||||
// The rows were never deleted — the group was merely shut. Prove the content
|
||||
// is still mounted so "wiped" can be ruled out for good.
|
||||
it('keeps the rows mounted even while collapsed', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={settledRows} turnActive={false} />);
|
||||
const group = screen.getByTestId('agent-task-insights');
|
||||
expect(group).toHaveProperty('open', false);
|
||||
expect(within(group).getByTestId('tool-timeline-viewport')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// The settled-turn contract: once the final result has landed the timeline
|
||||
// folds itself away so a long run never dominates the conversation, but the
|
||||
// escape hatch stays reachable — "View full agent process Source →" lives in
|
||||
// the always-visible <summary>, not in the collapsed body. Collapsing is only
|
||||
// acceptable BECAUSE that link survives, so both halves are asserted together.
|
||||
describe('ToolTimelineBlock — settled turn keeps the process-source escape hatch', () => {
|
||||
const settled: ToolTimelineEntry[] = [
|
||||
{ id: 's-1', name: 'read_file', round: 1, seq: 0, status: 'success', detail: 'a.ts' },
|
||||
{ id: 's-2', name: 'code_executor', round: 1, seq: 1, status: 'success', result: 'exit 0' },
|
||||
];
|
||||
|
||||
it('collapses after the final result but still exposes the process-source link', () => {
|
||||
const onViewWholeRun = vi.fn();
|
||||
renderInStore(
|
||||
<ToolTimelineBlock entries={settled} turnActive={false} onViewWholeRun={onViewWholeRun} />
|
||||
);
|
||||
|
||||
const group = screen.getByTestId('agent-task-insights');
|
||||
expect(group).not.toHaveAttribute('open');
|
||||
|
||||
// Link is in the <summary>, so it is reachable while collapsed.
|
||||
const link = screen.getByTestId('view-process-source');
|
||||
expect(link).toBeInTheDocument();
|
||||
|
||||
// Clicking it opens the full-run panel and must NOT toggle the disclosure
|
||||
// (the handler stops propagation to the summary's own click).
|
||||
fireEvent.click(link);
|
||||
expect(onViewWholeRun).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('agent-task-insights')).not.toHaveAttribute('open');
|
||||
});
|
||||
|
||||
it('still exposes the link while the turn is in flight', () => {
|
||||
const onViewWholeRun = vi.fn();
|
||||
renderInStore(
|
||||
<ToolTimelineBlock entries={settled} turnActive onViewWholeRun={onViewWholeRun} />
|
||||
);
|
||||
expect(screen.getByTestId('agent-task-insights')).toHaveAttribute('open');
|
||||
expect(screen.getByTestId('view-process-source')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Compact chat rows show raw tool output for FAILED steps only. A successful
|
||||
// step's output is already compressed into the agent's final answer, so
|
||||
// repeating it inline duplicated the answer and stacked one scrollable <pre>
|
||||
// per tool above it. A failure is where the answer is least trustworthy (it may
|
||||
// not mention the failure at all), so that evidence stays inline.
|
||||
describe('ToolTimelineBlock — compact rows show output only on failure', () => {
|
||||
const succeeded: ToolTimelineEntry = {
|
||||
id: 'r-ok',
|
||||
name: 'code_executor',
|
||||
round: 1,
|
||||
seq: 0,
|
||||
status: 'success',
|
||||
result: 'exit 0 — 42 passed',
|
||||
};
|
||||
const failed: ToolTimelineEntry = {
|
||||
id: 'r-err',
|
||||
name: 'code_executor',
|
||||
round: 1,
|
||||
seq: 1,
|
||||
status: 'error',
|
||||
result: 'exit 1 — 3 failed',
|
||||
};
|
||||
|
||||
it('omits the output blob for a successful compact row', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[succeeded]} onViewDetails={vi.fn()} />);
|
||||
// Still collapsed to its link — the output is reachable, just not inline.
|
||||
expect(screen.getByTestId('view-details')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tool-result-output')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the output blob for a failed compact row', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[failed]} onViewDetails={vi.fn()} />);
|
||||
expect(screen.getByTestId('tool-result-output').textContent).toContain('exit 1 — 3 failed');
|
||||
});
|
||||
|
||||
it('shows only the failure when a turn mixes successful and failed steps', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[succeeded, failed]} onViewDetails={vi.fn()} />);
|
||||
const outputs = screen.getAllByTestId('tool-result-output');
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(outputs[0].textContent).toContain('exit 1 — 3 failed');
|
||||
});
|
||||
|
||||
// The panel/expanded path is the full record and must be unaffected — a
|
||||
// successful result is still shown there.
|
||||
it('still shows successful output in the expanded/panel path', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[succeeded]} expandAllRows />);
|
||||
expect(screen.getByTestId('tool-result-output').textContent).toContain('exit 0 — 42 passed');
|
||||
});
|
||||
});
|
||||
|
||||
// The rail renders the turn's interleaved processing transcript — narration,
|
||||
// reasoning and tool steps in stream order — through the SAME
|
||||
// `ProcessingTranscriptView` the Agent Process Source panel uses, so the rail
|
||||
// is a windowed view of the panel rather than a second, divergent rendering.
|
||||
// Narration no longer lives in the chat stream and reasoning no longer has its
|
||||
// own bubble; both surface here.
|
||||
describe('ToolTimelineBlock — renders the processing transcript inline', () => {
|
||||
const entries: ToolTimelineEntry[] = [
|
||||
{ id: 'tx-1', name: 'web_fetch', round: 1, seq: 0, status: 'success', detail: 'example.com' },
|
||||
];
|
||||
|
||||
it('renders narration and tool steps from the transcript', () => {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock
|
||||
entries={entries}
|
||||
turnActive
|
||||
transcript={[
|
||||
{ kind: 'narration', round: 1, seq: 0, text: 'Let me get the data for both.' },
|
||||
{ kind: 'toolCall', round: 1, seq: 1, callId: 'tx-1' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const view = screen.getByTestId('processing-transcript');
|
||||
expect(view).toBeInTheDocument();
|
||||
expect(screen.getByTestId('processing-narration').textContent).toContain(
|
||||
'Let me get the data for both.'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the transcript inside the windowed viewport during a turn', () => {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock
|
||||
entries={entries}
|
||||
turnActive
|
||||
transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'Working…' }]}
|
||||
/>
|
||||
);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
expect(viewport.getAttribute('data-windowed')).toBe('true');
|
||||
expect(within(viewport).getByTestId('processing-transcript')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Legacy snapshots predate the transcript — those turns must still render.
|
||||
it('falls back to the tool-row list when no transcript is present', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={entries} turnActive />);
|
||||
expect(screen.queryByTestId('processing-transcript')).toBeNull();
|
||||
expect(screen.getByTestId('agent-task-insights')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back when the transcript is present but empty', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={entries} turnActive transcript={[]} />);
|
||||
expect(screen.queryByTestId('processing-transcript')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: swapping the rail's body to `ProcessingTranscriptView` dropped
|
||||
// nested sub-agent activity, because its `ToolRow` renders only title/detail/
|
||||
// failure and never reads `entry.subagent`. A delegated run collapsed to one
|
||||
// line and every child tool call it made became invisible — visible as the
|
||||
// process-source panel (which fell back to the row list) showing more tool
|
||||
// calls than the inline rail. `renderSubagent` injects the block back in;
|
||||
// injected rather than imported because ToolTimelineBlock already imports
|
||||
// ProcessingTranscriptView, so importing back would be a cycle.
|
||||
describe('ToolTimelineBlock — sub-agent activity survives the transcript path', () => {
|
||||
const subagentEntry: ToolTimelineEntry = {
|
||||
id: 'sa-tx',
|
||||
name: 'subagent:researcher',
|
||||
round: 1,
|
||||
seq: 0,
|
||||
status: 'running',
|
||||
subagent: {
|
||||
taskId: 'task-9',
|
||||
agentId: 'researcher',
|
||||
toolCalls: [
|
||||
{ callId: 'c1', toolName: 'web_search', status: 'success', elapsedMs: 120 },
|
||||
{ callId: 'c2', toolName: 'web_fetch', status: 'running' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('renders the sub-agent child tool calls inside the transcript rail', () => {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock
|
||||
entries={[subagentEntry]}
|
||||
turnActive
|
||||
transcript={[{ kind: 'toolCall', round: 1, seq: 0, callId: 'sa-tx' }]}
|
||||
/>
|
||||
);
|
||||
// Rendering through the transcript path…
|
||||
expect(screen.getByTestId('processing-transcript')).toBeInTheDocument();
|
||||
// …and the nested child run is present, not collapsed to one line.
|
||||
expect(screen.getByTestId('processing-subagent')).toBeInTheDocument();
|
||||
const calls = screen.getAllByTestId('subagent-tool-call');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].textContent).toContain('Searching the web');
|
||||
expect(calls[0].textContent).toContain('Done');
|
||||
// Human label, not the raw `web_fetch` slug.
|
||||
expect(calls[1].textContent).toContain('Fetching');
|
||||
expect(calls[1].textContent).toContain('Running');
|
||||
});
|
||||
|
||||
it('still renders child tool calls on the legacy row path (no transcript)', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[subagentEntry]} turnActive />);
|
||||
expect(screen.queryByTestId('processing-transcript')).toBeNull();
|
||||
expect(screen.getAllByTestId('subagent-tool-call')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The nested child run must live INSIDE the windowed viewport, and must not
|
||||
// introduce a scroll container of its own. A nested scroller would clamp its
|
||||
// own height, so a streaming child run would stop changing the outer content
|
||||
// height — the ResizeObserver would never fire and auto-follow would silently
|
||||
// stall mid-subagent, with the window pinned to stale content.
|
||||
it('nests the sub-agent inside the sliding window with no scroller of its own', () => {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock
|
||||
entries={[subagentEntry]}
|
||||
turnActive
|
||||
transcript={[{ kind: 'toolCall', round: 1, seq: 0, callId: 'sa-tx' }]}
|
||||
/>
|
||||
);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
expect(viewport.getAttribute('data-windowed')).toBe('true');
|
||||
|
||||
const subagent = within(viewport).getByTestId('processing-subagent');
|
||||
expect(subagent).toBeInTheDocument();
|
||||
|
||||
// Walk from the sub-agent up to the viewport: nothing between them may
|
||||
// scroll, or the outer window stops seeing the child run grow.
|
||||
for (let node = subagent; node && node !== viewport; node = node.parentElement!) {
|
||||
expect(node.className).not.toMatch(/overflow-(y-)?auto|overflow-(y-)?scroll/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-follow: the window pins to the newest activity as the turn streams.
|
||||
// jsdom ships no ResizeObserver, so the effect early-returns and this behaviour
|
||||
// is invisible to every other test in this file — stub one and drive it
|
||||
// directly, otherwise the single most user-visible property of the windowed
|
||||
// rail has no coverage at all.
|
||||
describe('ToolTimelineBlock — auto-follows the live edge', () => {
|
||||
const entries: ToolTimelineEntry[] = [
|
||||
{ id: 'af-1', name: 'web_fetch', round: 1, seq: 0, status: 'running', detail: 'example.com' },
|
||||
];
|
||||
const transcript = [
|
||||
{ kind: 'narration' as const, round: 1, seq: 0, text: 'Let me get the data.' },
|
||||
{ kind: 'toolCall' as const, round: 1, seq: 1, callId: 'af-1' },
|
||||
];
|
||||
|
||||
/** Installs a fake ResizeObserver and returns a trigger for its callback. */
|
||||
function stubResizeObserver() {
|
||||
const callbacks: Array<() => void> = [];
|
||||
class FakeResizeObserver {
|
||||
constructor(cb: () => void) {
|
||||
callbacks.push(cb);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver;
|
||||
return {
|
||||
fire: () => callbacks.forEach(cb => cb()),
|
||||
restore: () => {
|
||||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** jsdom reports 0 for all layout metrics — drive them explicitly. */
|
||||
function sizeViewport(el: HTMLElement, { scrollHeight = 600, clientHeight = 200 } = {}) {
|
||||
Object.defineProperty(el, 'scrollHeight', { value: scrollHeight, configurable: true });
|
||||
Object.defineProperty(el, 'clientHeight', { value: clientHeight, configurable: true });
|
||||
}
|
||||
|
||||
it('scrolls to the newest content when the transcript grows', () => {
|
||||
const ro = stubResizeObserver();
|
||||
try {
|
||||
renderInStore(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
sizeViewport(viewport);
|
||||
viewport.scrollTop = 0;
|
||||
|
||||
ro.fire();
|
||||
|
||||
// Pinned to the live edge.
|
||||
expect(viewport.scrollTop).toBe(600);
|
||||
} finally {
|
||||
ro.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops following once the user scrolls away from the bottom', () => {
|
||||
const ro = stubResizeObserver();
|
||||
try {
|
||||
renderInStore(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
sizeViewport(viewport);
|
||||
|
||||
// User scrolls up to read an earlier step (well outside the 24px slack).
|
||||
viewport.scrollTop = 100;
|
||||
fireEvent.scroll(viewport);
|
||||
|
||||
ro.fire();
|
||||
|
||||
// Left where the user put it — not yanked back down.
|
||||
expect(viewport.scrollTop).toBe(100);
|
||||
} finally {
|
||||
ro.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('resumes following when the user scrolls back to the bottom', () => {
|
||||
const ro = stubResizeObserver();
|
||||
try {
|
||||
renderInStore(<ToolTimelineBlock entries={entries} turnActive transcript={transcript} />);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
sizeViewport(viewport);
|
||||
|
||||
viewport.scrollTop = 100; // detach
|
||||
fireEvent.scroll(viewport);
|
||||
viewport.scrollTop = 400; // back at the bottom (600 - 200 = 400)
|
||||
fireEvent.scroll(viewport);
|
||||
|
||||
ro.fire();
|
||||
|
||||
expect(viewport.scrollTop).toBe(600);
|
||||
} finally {
|
||||
ro.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not follow when the turn has settled (not windowed)', () => {
|
||||
const ro = stubResizeObserver();
|
||||
try {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock entries={entries} turnActive={false} transcript={transcript} />
|
||||
);
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
sizeViewport(viewport);
|
||||
viewport.scrollTop = 0;
|
||||
|
||||
ro.fire();
|
||||
|
||||
expect(viewport.scrollTop).toBe(0);
|
||||
} finally {
|
||||
ro.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: auto-follow silently never armed in a real turn.
|
||||
//
|
||||
// The observer used to attach in `useEffect(..., [windowed])`. `windowed` flips
|
||||
// true at the START of a turn — when there is no content yet, so the component
|
||||
// returned null, the ref was null, and the effect bailed. Content arriving
|
||||
// afterwards re-rendered the viewport but did not change `windowed`, so the
|
||||
// effect never re-ran and no observer was ever created. Every earlier test
|
||||
// passed because it rendered with content already present at mount, which is
|
||||
// precisely the case that never happens live.
|
||||
describe('ToolTimelineBlock — auto-follow arms when content arrives after mount', () => {
|
||||
function stubResizeObserver() {
|
||||
const callbacks: Array<() => void> = [];
|
||||
class FakeResizeObserver {
|
||||
constructor(cb: () => void) {
|
||||
callbacks.push(cb);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = FakeResizeObserver;
|
||||
return {
|
||||
fire: () => callbacks.forEach(cb => cb()),
|
||||
count: () => callbacks.length,
|
||||
restore: () => {
|
||||
delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('follows content that only appears on a later render', () => {
|
||||
const ro = stubResizeObserver();
|
||||
try {
|
||||
// Turn starts: windowed, but nothing to show yet → renders nothing.
|
||||
const { rerender } = renderInStore(
|
||||
<ToolTimelineBlock entries={[]} turnActive transcript={[]} />
|
||||
);
|
||||
expect(screen.queryByTestId('tool-timeline-viewport')).toBeNull();
|
||||
expect(ro.count()).toBe(0);
|
||||
|
||||
// …then the first tool row lands.
|
||||
rerender(
|
||||
<Provider store={store}>
|
||||
<ToolTimelineBlock
|
||||
entries={[{ id: 'late-1', name: 'web_fetch', round: 1, seq: 0, status: 'running' }]}
|
||||
turnActive
|
||||
transcript={[]}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const viewport = screen.getByTestId('tool-timeline-viewport');
|
||||
Object.defineProperty(viewport, 'scrollHeight', { value: 500, configurable: true });
|
||||
Object.defineProperty(viewport, 'clientHeight', { value: 200, configurable: true });
|
||||
viewport.scrollTop = 0;
|
||||
|
||||
// The observer must have been created for the node that appeared late.
|
||||
expect(ro.count()).toBeGreaterThan(0);
|
||||
ro.fire();
|
||||
expect(viewport.scrollTop).toBe(500);
|
||||
} finally {
|
||||
ro.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Narration streams before the first tool call, so gating the render on
|
||||
// `entries` alone blanked the rail for the opening stretch of every turn and
|
||||
// hid tool-less turns entirely.
|
||||
it('renders on transcript alone, with no tool rows yet', () => {
|
||||
renderInStore(
|
||||
<ToolTimelineBlock
|
||||
entries={[]}
|
||||
turnActive
|
||||
transcript={[{ kind: 'narration', round: 1, seq: 0, text: 'Let me get the data.' }]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('tool-timeline-viewport')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('processing-narration').textContent).toContain(
|
||||
'Let me get the data.'
|
||||
);
|
||||
});
|
||||
|
||||
it('still renders nothing when there is neither a row nor transcript prose', () => {
|
||||
renderInStore(<ToolTimelineBlock entries={[]} turnActive transcript={[]} />);
|
||||
expect(screen.queryByTestId('agent-task-insights')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ThreadMessage } from '../../../types/thread';
|
||||
import { supersededInterimIndexes } from './interimNarration';
|
||||
|
||||
function msg(
|
||||
sender: 'user' | 'agent',
|
||||
content: string,
|
||||
extraMetadata?: Record<string, unknown>
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id: `${sender}-${content.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
sender,
|
||||
content,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
...(extraMetadata ? { extraMetadata } : {}),
|
||||
} as unknown as ThreadMessage;
|
||||
}
|
||||
|
||||
const interim = (text: string) => msg('agent', text, { isInterim: true, requestId: 'r1' });
|
||||
const answer = (text: string) => msg('agent', text, { citations: [] });
|
||||
|
||||
describe('supersededInterimIndexes', () => {
|
||||
it('hides narration once the turn produced its answer', () => {
|
||||
const messages = [
|
||||
msg('user', 'how many goals?'),
|
||||
interim('Let me get the data for both.'),
|
||||
interim('The HTML is hard to parse. Let me search for a clean table.'),
|
||||
answer('He scored 11 goals.'),
|
||||
];
|
||||
expect([...supersededInterimIndexes(messages)].sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('keeps narration while the turn is still in flight (no answer yet)', () => {
|
||||
const messages = [
|
||||
msg('user', 'how many goals?'),
|
||||
interim('Let me get the data for both.'),
|
||||
interim('Let me get a cleaner source.'),
|
||||
];
|
||||
expect(supersededInterimIndexes(messages).size).toBe(0);
|
||||
});
|
||||
|
||||
// A turn that errored before answering: its narration is the only record of
|
||||
// what actually ran, so it must survive.
|
||||
it('keeps narration for a turn that died before answering', () => {
|
||||
const messages = [
|
||||
msg('user', 'first question'),
|
||||
interim('Let me check.'),
|
||||
answer('Here is the answer.'),
|
||||
msg('user', 'second question'),
|
||||
interim('Let me search.'),
|
||||
];
|
||||
// Only the FIRST turn's narration is superseded.
|
||||
expect([...supersededInterimIndexes(messages)]).toEqual([1]);
|
||||
});
|
||||
|
||||
it('scopes per turn across a multi-turn thread', () => {
|
||||
const messages = [
|
||||
msg('user', 'q1'),
|
||||
interim('n1'),
|
||||
answer('a1'),
|
||||
msg('user', 'q2'),
|
||||
interim('n2'),
|
||||
interim('n3'),
|
||||
answer('a2'),
|
||||
];
|
||||
expect([...supersededInterimIndexes(messages)].sort((a, b) => a - b)).toEqual([1, 4, 5]);
|
||||
});
|
||||
|
||||
it('never hides real answers or user messages', () => {
|
||||
const messages = [msg('user', 'q'), interim('n'), answer('a')];
|
||||
const hidden = supersededInterimIndexes(messages);
|
||||
expect(hidden.has(0)).toBe(false);
|
||||
expect(hidden.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles an empty thread and an interim-only thread with no user message', () => {
|
||||
expect(supersededInterimIndexes([]).size).toBe(0);
|
||||
// Proactive-only run: narration with no answer yet stays visible.
|
||||
expect(supersededInterimIndexes([interim('working…')]).size).toBe(0);
|
||||
// Proactive-only run that did answer: narration is superseded.
|
||||
expect([...supersededInterimIndexes([interim('working…'), answer('done')])]).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Which interim narration bubbles a finished turn has superseded.
|
||||
*
|
||||
* The agent emits `extraMetadata.isInterim` messages while it works ("Let me
|
||||
* get the data for both.", "The HTML is hard to parse. Let me search for a
|
||||
* clean table.") — these are live progress, not content. Once the turn delivers
|
||||
* its real answer they are superseded; left unfiltered they pile up
|
||||
* permanently, wedging several stale "Let me…" bubbles between the question and
|
||||
* the answer on every multi-tool turn.
|
||||
*
|
||||
* The rule is per TURN, keyed on that turn having produced a final
|
||||
* (non-interim) agent message:
|
||||
*
|
||||
* - turn still in flight → no final message yet → narration stays visible
|
||||
* - turn answered → narration hidden, the answer speaks for it
|
||||
* - turn died first → narration kept; it is the only record of what ran
|
||||
*
|
||||
* Deriving this from the answer's existence rather than a turn-active flag is
|
||||
* what makes the third case work, and keeps the helper pure (no lifecycle
|
||||
* plumbing, trivially testable).
|
||||
*
|
||||
* Nothing is deleted — callers use this to filter the rendered list only. The
|
||||
* messages stay persisted and reachable via "View full agent process Source".
|
||||
*/
|
||||
import type { ThreadMessage } from '../../../types/thread';
|
||||
|
||||
/**
|
||||
* Indexes into `messages` whose interim narration is superseded and should not
|
||||
* render. Returns indexes (not ids) so callers can filter positionally without
|
||||
* assuming ids are present or unique.
|
||||
*/
|
||||
export function supersededInterimIndexes(messages: readonly ThreadMessage[]): Set<number> {
|
||||
const hidden = new Set<number>();
|
||||
let segmentStart = 0;
|
||||
|
||||
// A turn spans (previous user message, next user message]. Closing a segment
|
||||
// hides its narration only when that segment also produced a real answer.
|
||||
const closeSegment = (end: number) => {
|
||||
let hasFinalAnswer = false;
|
||||
for (let i = segmentStart; i < end; i += 1) {
|
||||
const msg = messages[i];
|
||||
if (msg.sender === 'agent' && !msg.extraMetadata?.isInterim) {
|
||||
hasFinalAnswer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasFinalAnswer) return;
|
||||
for (let i = segmentStart; i < end; i += 1) {
|
||||
if (messages[i].extraMetadata?.isInterim) hidden.add(i);
|
||||
}
|
||||
};
|
||||
|
||||
messages.forEach((msg, index) => {
|
||||
if (msg.sender === 'user') {
|
||||
closeSegment(index);
|
||||
segmentStart = index + 1;
|
||||
}
|
||||
});
|
||||
closeSegment(messages.length);
|
||||
|
||||
return hidden;
|
||||
}
|
||||
@@ -827,33 +827,36 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return;
|
||||
const content = event.full_response?.trim() ?? '';
|
||||
if (!content) return;
|
||||
// Persist this round's leading narration as its own interleaved bubble,
|
||||
// stamped with the producing turn's request id (Phase 4 anchoring).
|
||||
// `isInterim: true` marks this as between-tool narration rather than a
|
||||
// turn's terminal answer — the main chat still renders it as a bubble
|
||||
// (unchanged), but callers that only want the terminal turn (e.g. the
|
||||
// Flows copilot's `displayMessages`, see `useWorkflowBuilderChat`) can
|
||||
// filter it out.
|
||||
rtLog('interim_narration_tagged', {
|
||||
// Narration is NOT promoted to a chat message any more.
|
||||
//
|
||||
// It used to be persisted here via `addInferenceResponse({ isInterim })`,
|
||||
// which gave it a lifetime no other progress signal has: thinking is
|
||||
// wiped at `chat_done`, tool rows collapse, but narration bubbles
|
||||
// ("Let me get the data for both.", "The HTML is hard to parse…")
|
||||
// stayed in the thread forever, wedged between the question and the
|
||||
// answer they were superseded by.
|
||||
//
|
||||
// It is already captured twice over without this: `streamDeltaReceived`
|
||||
// coalesces every `content` delta into `processingByThread` as a
|
||||
// `narration` transcript item (chatRuntimeSlice), and the core persists
|
||||
// the same thing server-side as `TranscriptItem::Narration`, kept after
|
||||
// completion so a reload replays it. The inline rail and the Agent
|
||||
// Process Source panel both render that transcript — so narration is
|
||||
// still fully visible while the turn runs, and still inspectable after,
|
||||
// just not as a permanent chat bubble.
|
||||
//
|
||||
// The event is still consumed (not dropped upstream) for its dedup key
|
||||
// and the preview reset below, both of which are round-scoped.
|
||||
rtLog('interim_narration_observed', {
|
||||
thread: event.thread_id,
|
||||
request: event.request_id,
|
||||
round: event.round,
|
||||
});
|
||||
void dispatch(
|
||||
addInferenceResponse({
|
||||
content,
|
||||
threadId: event.thread_id,
|
||||
extraMetadata: {
|
||||
isInterim: true,
|
||||
...(event.request_id ? { requestId: event.request_id } : {}),
|
||||
},
|
||||
})
|
||||
);
|
||||
// The narration has now become a bubble, so drop it from the live
|
||||
// streaming preview (which accumulates across the whole turn under one
|
||||
// request_id) — otherwise the same text lingers in the preview tail and
|
||||
// reads as a duplicate for the full duration of the tool call. Reset
|
||||
// synchronously so the next round's deltas start from an empty buffer.
|
||||
// Drop the round's narration from the live streaming preview, which
|
||||
// accumulates across the whole turn under one request_id. This matters
|
||||
// MORE now: without it the same text renders both in the rail (as a
|
||||
// transcript item) and in the preview tail. Reset synchronously so the
|
||||
// next round's deltas start from an empty buffer.
|
||||
const cr = store.getState().chatRuntime;
|
||||
const existing = cr.streamingAssistantByThread[event.thread_id];
|
||||
if (existing && existing.requestId === event.request_id) {
|
||||
|
||||
@@ -1018,7 +1018,15 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
expect(streaming?.content).toBe('bbb');
|
||||
});
|
||||
|
||||
it('persists interim narration as a bubble and clears it from the live preview', async () => {
|
||||
// Narration is NOT promoted to a chat message. It used to be persisted via
|
||||
// `addInferenceResponse({ isInterim: true })`, which gave it a lifetime no
|
||||
// other progress signal has — thinking is wiped at `chat_done` and tool rows
|
||||
// collapse, but narration bubbles stayed in the thread forever, between the
|
||||
// question and the answer that superseded them. It reaches the UI through
|
||||
// the processing transcript instead (written by `streamDeltaReceived`, and
|
||||
// persisted core-side as `TranscriptItem::Narration`), which the inline rail
|
||||
// and the Agent Process Source panel both render.
|
||||
it('records interim narration in the transcript, not as a message, and clears the preview', async () => {
|
||||
const listeners = renderProvider();
|
||||
|
||||
// Round-0 narration streams into the live preview…
|
||||
@@ -1033,6 +1041,11 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe(
|
||||
'Let me check your calendar first.'
|
||||
);
|
||||
// …and is already captured as a narration transcript item by the delta
|
||||
// reducer — this is what the rail renders.
|
||||
expect(store.getState().chatRuntime.processingByThread['t-interim']).toEqual([
|
||||
expect.objectContaining({ kind: 'narration', text: 'Let me check your calendar first.' }),
|
||||
]);
|
||||
|
||||
// …then a tool call closes the round → interim flush.
|
||||
act(() => {
|
||||
@@ -1044,30 +1057,49 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
});
|
||||
});
|
||||
|
||||
// The narration is persisted as its own bubble…
|
||||
await waitFor(() =>
|
||||
expect(threadApi.appendMessage).toHaveBeenCalledWith(
|
||||
't-interim',
|
||||
expect.objectContaining({ content: 'Let me check your calendar first.', sender: 'agent' })
|
||||
)
|
||||
);
|
||||
// …and cleared from the live preview so it isn't shown twice.
|
||||
// No message is appended for narration.
|
||||
expect(threadApi.appendMessage).not.toHaveBeenCalled();
|
||||
// The preview is still cleared, so the rail and the preview don't both
|
||||
// show the same text for the duration of the tool call.
|
||||
expect(store.getState().chatRuntime.streamingAssistantByThread['t-interim']?.content).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('dedupes a re-delivered interim event by round', async () => {
|
||||
// The round dedup key outlives the message-promotion it was written for:
|
||||
// it now guards the streaming-preview reset. A replayed frame must not wipe
|
||||
// text the agent streamed AFTER the original flush.
|
||||
it('dedupes a re-delivered interim event by round', () => {
|
||||
const listeners = renderProvider();
|
||||
const streamingContent = () =>
|
||||
store.getState().chatRuntime.streamingAssistantByThread['t-interim-dup']?.content;
|
||||
|
||||
act(() => {
|
||||
listeners.onTextDelta?.({
|
||||
thread_id: 't-interim-dup',
|
||||
request_id: 'r1',
|
||||
round: 1,
|
||||
delta: 'Working on it now — pulling the data.',
|
||||
});
|
||||
listeners.onInterim?.({
|
||||
thread_id: 't-interim-dup',
|
||||
request_id: 'r1',
|
||||
round: 1,
|
||||
full_response: 'Working on it now — pulling the data.',
|
||||
});
|
||||
// Reconnect/replay re-delivers the same round.
|
||||
});
|
||||
// First delivery flushes the round and clears the preview.
|
||||
expect(streamingContent()).toBe('');
|
||||
|
||||
act(() => {
|
||||
// The agent streams the next chunk…
|
||||
listeners.onTextDelta?.({
|
||||
thread_id: 't-interim-dup',
|
||||
request_id: 'r1',
|
||||
round: 1,
|
||||
delta: 'Here is what I found.',
|
||||
});
|
||||
// …and a reconnect/replay re-delivers the SAME round.
|
||||
listeners.onInterim?.({
|
||||
thread_id: 't-interim-dup',
|
||||
request_id: 'r1',
|
||||
@@ -1076,7 +1108,11 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1));
|
||||
// Deduped: the replay is ignored, so the newer text survives. Without the
|
||||
// guard the preview would have been cleared a second time.
|
||||
expect(streamingContent()).toBe('Here is what I found.');
|
||||
// And narration still never becomes a message, on either delivery.
|
||||
expect(threadApi.appendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets inference status to thinking on inference_start and clears it on chat_done', () => {
|
||||
|
||||
@@ -299,7 +299,34 @@ impl SubagentPayloadSummarizer {
|
||||
Arc::new(harness),
|
||||
)
|
||||
.with_system_prompt(system_prompt);
|
||||
let run = child.invoke_in_parent(&(), (), parent_ctx, prompt).await?;
|
||||
// Run the summarizer UNARY (non-streaming), not via `invoke_in_parent`.
|
||||
//
|
||||
// `invoke_in_parent` inherits `parent.streaming`, which is `true` for a
|
||||
// chat turn, so the child runs the streaming loop and its per-token
|
||||
// deltas land on the shared `EventSink` as parent `AgentProgress::
|
||||
// TextDelta`. The web bridge buffers those into `pending_narration`
|
||||
// and `flush_interim_narration` publishes them as a `chat_interim`
|
||||
// bubble, which is persisted as an `isInterim` agent message — so the
|
||||
// internal "[Tool output summary — <tool>]" text was rendered to the
|
||||
// user as if it were part of the answer. That defeats the point of the
|
||||
// summarizer: it exists to compress a payload for the ORCHESTRATOR'S
|
||||
// CONTEXT, and its only consumer here is `run.text()` below.
|
||||
//
|
||||
// `invoke_with_events` runs the child through the unary path
|
||||
// (`run_child(.., streaming = false)`), which per its own contract
|
||||
// "leav[es] the parent's event stream unchanged", while still sharing
|
||||
// the sink so the sub-agent lifecycle events (started/completed) keep
|
||||
// reaching observers. Mirrors `reprompt_for_required_block`, which is
|
||||
// likewise deliberately silent about an internal repair call.
|
||||
//
|
||||
// Two bits of config that `invoke_in_parent` threaded are dropped by
|
||||
// this entry point and neither matters here: the child `thread_id` (only
|
||||
// used to attribute events we no longer stream) and the inherited
|
||||
// `max_turn_output_tokens` (already enforced independently by the
|
||||
// `MaxTokensModel` wrapper above).
|
||||
let run = child
|
||||
.invoke_with_events(&(), (), parent_ctx.depth(), prompt, &parent_ctx.events)
|
||||
.await?;
|
||||
Ok(run.text().unwrap_or_default())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user