- {selectedStreamingAssistant.thinking.slice(-STREAMING_PREVIEW_CHARS)}
-
- - {selectedStreamingAssistant.content.length > - STREAMING_PREVIEW_CHARS && ( - … - )} - {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} - -
-+ {selectedStreamingAssistant.content.length > STREAMING_PREVIEW_CHARS && ( + … + )} + {selectedStreamingAssistant.content.slice(-STREAMING_PREVIEW_CHARS)} + +
+
- {resultContent}
-
- ) : null}
- 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' ? (
+
+ {resultContent}
+
+ ) : null}
+ {detailContent}
+
+ ) : null}
+ {resultContent ? (
+ // What the tool returned (size-capped upstream). Scrolls
+ // inside its own box so a long result never floods the
+ // timeline.
+
+ {resultContent}
+
+ ) : null}
+ {subagent ? (
+
- {detailContent}
-
- ) : null}
- {resultContent ? (
- // What the tool returned (size-capped upstream). Scrolls
- // inside its own box so a long result never floods the
- // timeline.
-
- {resultContent}
-
- ) : null}
- {subagent ? (
-
+// 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( );
+ // 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( );
+ 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( );
+ 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( );
+ 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(
+
+ );
+ 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(
+
+ );
+ 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( );
+ expect(screen.queryByTestId('processing-transcript')).toBeNull();
+ expect(screen.getByTestId('agent-task-insights')).toBeInTheDocument();
+ });
+
+ it('falls back when the transcript is present but empty', () => {
+ renderInStore( );
+ 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(
+
+ );
+ // 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( );
+ 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(
+
+ );
+ 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( );
+ 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( );
+ 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( );
+ 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(
+
+ );
+ 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(
+
+ );
+ expect(screen.queryByTestId('tool-timeline-viewport')).toBeNull();
+ expect(ro.count()).toBe(0);
+
+ // …then the first tool row lands.
+ rerender(
+
+
+
+ );
+
+ 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(
+
+ );
+ 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( );
+ expect(screen.queryByTestId('agent-task-insights')).toBeNull();
+ });
+});
diff --git a/app/src/features/conversations/utils/interimNarration.test.ts b/app/src/features/conversations/utils/interimNarration.test.ts
new file mode 100644
index 0000000000..a882891df9
--- /dev/null
+++ b/app/src/features/conversations/utils/interimNarration.test.ts
@@ -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
+): 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]);
+ });
+});
diff --git a/app/src/features/conversations/utils/interimNarration.ts b/app/src/features/conversations/utils/interimNarration.ts
new file mode 100644
index 0000000000..299226ed5f
--- /dev/null
+++ b/app/src/features/conversations/utils/interimNarration.ts
@@ -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 {
+ const hidden = new Set();
+ 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;
+}
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index da86437903..3a73ccf02c 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -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) {
diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
index ed25001852..a6aa97d9c8 100644
--- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
+++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
@@ -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', () => {
diff --git a/src/openhuman/tinyagents/payload_summarizer.rs b/src/openhuman/tinyagents/payload_summarizer.rs
index 825c331edb..6b610e479c 100644
--- a/src/openhuman/tinyagents/payload_summarizer.rs
+++ b/src/openhuman/tinyagents/payload_summarizer.rs
@@ -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 — ]" 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())
}