{scopedDetail}
@@ -195,7 +195,7 @@ export function AgentProcessSourcePanel({
}
+ renderSubagent={subagent => }
/>
) : entries.length > 0 ? (
// Legacy snapshot (no transcript): fall back to the tool timeline,
@@ -223,7 +223,7 @@ export function AgentProcessSourcePanel({
{formatTimelineEntry(entry).title}
-
+
))}
diff --git a/app/src/features/conversations/components/AssistantUiSubagentCall.tsx b/app/src/features/conversations/components/AssistantUiSubagentCall.tsx
new file mode 100644
index 0000000000..fc0b29b3f7
--- /dev/null
+++ b/app/src/features/conversations/components/AssistantUiSubagentCall.tsx
@@ -0,0 +1,227 @@
+import { CheckIcon, ChevronDownIcon, CircleXIcon, Loader2Icon, WorkflowIcon } from 'lucide-react';
+
+import { cn } from '../../../components/assistant-ui/lib/utils';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '../../../components/assistant-ui/ui/collapsible';
+import Badge from '../../../components/ui/Badge';
+import WorktreeActions from '../../../components/worktree/WorktreeActions';
+import { useT } from '../../../lib/i18n/I18nContext';
+import type {
+ SubagentActivity,
+ SubagentToolCallEntry,
+ SubagentTranscriptItem,
+} from '../../../store/chatRuntimeSlice';
+import { basename } from '../../../utils/pathUtils';
+import { stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting';
+import { BubbleMarkdown } from './AgentMessageBubble';
+import { AssistantUiToolCallCard } from './AssistantUiToolCall';
+
+type ChildToolCall = SubagentToolCallEntry | Extract;
+
+function ChildToolCallCard({ call }: { call: ChildToolCall }) {
+ return (
+
+ );
+}
+
+function Thought({ text }: { text: string }) {
+ const clean = stripToolCallEnvelopes(text).trim();
+ if (!clean) return null;
+ return (
+
+
+
+ );
+}
+
+function SubagentDetails({
+ subagent,
+ onView,
+}: {
+ subagent: SubagentActivity;
+ onView?: () => void;
+}) {
+ const { t } = useT();
+ const headerBits: string[] = [];
+ if (subagent.mode) headerBits.push(subagent.mode);
+ if (subagent.dedicatedThread) headerBits.push(t('conversations.toolTimeline.workerThread'));
+ if (subagent.childIteration != null) {
+ headerBits.push(
+ subagent.childMaxIterations != null
+ ? `${t('conversations.toolTimeline.turn')} ${subagent.childIteration}/${subagent.childMaxIterations}`
+ : `${t('conversations.toolTimeline.step')} ${subagent.childIteration}`
+ );
+ } else if (subagent.iterations != null) {
+ headerBits.push(
+ subagent.iterations === 1
+ ? `${subagent.iterations} ${t('chat.turn')}`
+ : `${subagent.iterations} ${t('chat.turns')}`
+ );
+ }
+ if (subagent.elapsedMs != null) {
+ headerBits.push(
+ subagent.elapsedMs >= 1000
+ ? `${(subagent.elapsedMs / 1000).toFixed(1)}s`
+ : `${subagent.elapsedMs}ms`
+ );
+ }
+ const transcript = subagent.transcript ?? [];
+
+ return (
+
+ {headerBits.length > 0 ? (
+
+ {headerBits.map(bit => (
+
+ {bit}
+
+ ))}
+
+ ) : null}
+ {transcript.length > 0 ? (
+
+ {transcript.map((item, index) =>
+ item.kind === 'tool' ? (
+
+ ) : (
+
+ )
+ )}
+
+ ) : subagent.toolCalls.length > 0 ? (
+
+ {subagent.toolCalls.map(call => (
+
+ ))}
+
+ ) : null}
+ {subagent.worktreePath ? (
+
+
+ {t('worktree.label')}
+
+ {basename(subagent.worktreePath)}
+
+
+ {subagent.isDirty ? t('worktree.dirty') : t('worktree.clean')}
+
+ {subagent.changedFiles?.length ? (
+
+ {subagent.changedFiles.length}{' '}
+ {subagent.changedFiles.length === 1
+ ? t('worktree.changedFile')
+ : t('worktree.changedFiles')}
+
+ ) : null}
+
+
+
+ ) : null}
+ {onView ? (
+
+ ) : null}
+
+ );
+}
+
+/**
+ * Statuses that mean the delegation is still in flight.
+ *
+ * `SubagentActivity.status` carries `running` | `awaiting_user` | `completed` |
+ * `failed`, and collapsing that to a boolean is what produced two opposite
+ * rendering bugs: a caller that omitted `running` showed a *failed* delegation
+ * with a success check, while `status !== 'completed'` gave the same row an
+ * endless spinner. Both call sites now ask this one question.
+ */
+export function isActiveSubagentStatus(status: string | undefined): boolean {
+ return status === 'running' || status === 'awaiting_user';
+}
+
+/** Statuses that mean the delegation stopped without succeeding. */
+function isFailedSubagentStatus(status: string | undefined): boolean {
+ return status === 'failed' || status === 'cancelled';
+}
+
+export function AssistantUiSubagentCall({
+ activity,
+ running,
+ description,
+ onView,
+ defaultOpen = false,
+}: {
+ activity: SubagentActivity;
+ running?: boolean;
+ description?: string;
+ onView?: () => void;
+ defaultOpen?: boolean;
+}) {
+ const name = activity.displayName ?? activity.agentId ?? 'subagent';
+ // Default to the activity's own lifecycle rather than `false`: most call
+ // sites pass no `running` prop at all, and treating every non-running
+ // activity as finished-successfully is what rendered a failed delegation
+ // with a success check.
+ const active = running ?? isActiveSubagentStatus(activity.status);
+ const failed = !active && isFailedSubagentStatus(activity.status);
+ return (
+
+
+
+
+ Delegated to {name}
+
+ {active ? (
+
+ running
+
+ ) : (
+
+ {failed ? : }
+ {failed ? {activity.status} : null}
+ {activity.elapsedMs != null ? (
+ {(activity.elapsedMs / 1000).toFixed(1)}s
+ ) : null}
+
+ )}
+
+
+
+ {description ? {description}
: null}
+
+
+
+ );
+}
diff --git a/app/src/features/conversations/components/AssistantUiToolCall.tsx b/app/src/features/conversations/components/AssistantUiToolCall.tsx
new file mode 100644
index 0000000000..4cf79e6bde
--- /dev/null
+++ b/app/src/features/conversations/components/AssistantUiToolCall.tsx
@@ -0,0 +1,250 @@
+import type { ToolCallMessagePartComponent } from '@assistant-ui/react';
+import { CheckIcon, ChevronDownIcon, CircleXIcon, Loader2Icon, WrenchIcon } from 'lucide-react';
+
+import { cn } from '../../../components/assistant-ui/lib/utils';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '../../../components/assistant-ui/ui/collapsible';
+import type {
+ ToolFailureExplanation,
+ ToolTimelineEntryStatus,
+} from '../../../store/chatRuntimeSlice';
+import { formatToolName } from '../../../utils/toolTimelineFormatting';
+import { BubbleMarkdown } from './AgentMessageBubble';
+import { ToolFailureLines } from './ToolFailureLines';
+
+function friendlyLabel(key: string): string {
+ return key
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/[_-]+/g, ' ')
+ .replace(/^./, char => char.toUpperCase());
+}
+
+function parsedValue(value: unknown): unknown {
+ if (typeof value !== 'string') return value;
+ const trimmed = value.trim();
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return value;
+ }
+}
+
+function hasDisplayValue(value: unknown): boolean {
+ if (value === undefined || value === null || value === '') return false;
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value === 'object') return Object.keys(value as object).length > 0;
+ return true;
+}
+
+function ToolDataView({ value }: { value: unknown }) {
+ const parsed = parsedValue(value);
+ if (Array.isArray(parsed)) {
+ return (
+
+ {parsed.map((item, index) => (
+ -
+
+
+ ))}
+
+ );
+ }
+ if (parsed && typeof parsed === 'object') {
+ const entries = Object.entries(parsed);
+ for (const key of ['content', 'output', 'result', 'message', 'query', 'q']) {
+ const semantic = entries.find(([candidate]) => candidate === key)?.[1];
+ if (hasDisplayValue(semantic)) return ;
+ }
+ return (
+
+ {entries.map(([key, item]) => (
+
+ - {friendlyLabel(key)}
+ -
+
+
+
+ ))}
+
+ );
+ }
+ if (typeof parsed === 'boolean') return {parsed ? 'Yes' : 'No'};
+ if (typeof parsed === 'string') return ;
+ return {String(parsed ?? '')};
+}
+
+function inferredToolLabel(toolName: string, running: boolean, args: unknown, result: unknown) {
+ const lowerName = toolName.toLowerCase();
+ const parsedArgs = parsedValue(args);
+ const argKeys =
+ parsedArgs && typeof parsedArgs === 'object' && !Array.isArray(parsedArgs)
+ ? Object.keys(parsedArgs as object).map(key => key.toLowerCase())
+ : [];
+ const renderedResult = typeof result === 'string' ? result : JSON.stringify(result ?? '');
+ const looksLikeSearch =
+ lowerName.includes('search') ||
+ argKeys.some(key => ['query', 'q', 'search_query'].includes(key)) ||
+ /(?:^|\n)#?\s*search results\b/i.test(renderedResult);
+ const looksLikeFetch =
+ lowerName.includes('fetch') ||
+ argKeys.some(key => ['url', 'uri'].includes(key)) ||
+ /\bstatus=\d{3}\s+url=/i.test(renderedResult);
+ if (looksLikeSearch) return running ? 'Searching the web' : 'Searched the web';
+ if (looksLikeFetch) return running ? 'Fetching from the web' : 'Fetched from the web';
+ return formatToolName(toolName);
+}
+
+export interface AssistantUiToolCallCardProps {
+ toolName: string;
+ args?: unknown;
+ argsText?: string;
+ result?: unknown;
+ status?: ToolTimelineEntryStatus;
+ displayName?: string;
+ detail?: string;
+ elapsedMs?: number;
+ failure?: ToolFailureExplanation;
+}
+
+/** The single assistant-ui tool-call presentation used at every nesting level. */
+export function AssistantUiToolCallCard({
+ toolName,
+ args,
+ argsText,
+ result,
+ status,
+ displayName,
+ detail,
+ elapsedMs,
+ failure,
+}: AssistantUiToolCallCardProps) {
+ const running = status
+ ? status === 'running' || status === 'awaiting_user'
+ : result === undefined;
+ const input = hasDisplayValue(args) ? args : parsedValue(argsText ?? '');
+ const output = result === '' && status && !running ? 'No output' : parsedValue(result);
+ const suppliedLabel = displayName?.trim();
+ const label =
+ suppliedLabel && suppliedLabel.toLowerCase() !== 'tool'
+ ? suppliedLabel
+ : inferredToolLabel(toolName, running, args, result);
+ const statusLabel =
+ status === 'error'
+ ? 'failed'
+ : status === 'cancelled'
+ ? 'cancelled'
+ : status === 'awaiting_user'
+ ? 'awaiting input'
+ : running
+ ? 'running'
+ : 'done';
+ const failed = status === 'error';
+ // `failed` gates the failure-explanation block, which only an `error` carries.
+ // The icon is a wider question: a cancelled call did not succeed either, and
+ // before the adapter forwarded a status this branch was unreachable, so the
+ // check icon sat next to the word "cancelled".
+ const terminalNonSuccess = failed || status === 'cancelled';
+
+ return (
+
+
+
+ {label}
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+
+ {running ? (
+
+ ) : terminalNonSuccess ? (
+
+ ) : (
+
+ )}
+ {statusLabel}
+ {elapsedMs != null && !running ? (
+
+ {elapsedMs >= 1000 ? `${(elapsedMs / 1000).toFixed(1)}s` : `${elapsedMs}ms`}
+
+ ) : null}
+
+
+
+ {failed && failure ? (
+
+
+
+ ) : null}
+
+ {hasDisplayValue(input) ? (
+
+ Input
+
+
+
+
+ ) : null}
+ {hasDisplayValue(output) ? (
+
+ Output
+
+
+
+
+ ) : null}
+
+
+ );
+}
+
+/**
+ * Terminal status carried inside a settled tool part's `result`.
+ *
+ * assistant-ui's tool-call part has no status field, so `toolPart` puts the
+ * status there for a tool that failed or was cancelled (`value` holds the real
+ * output when there was one). Without unwrapping it here the card fell back to
+ * `result !== undefined`, which reads as success — a failed tool rendered
+ * "done" with a check.
+ */
+function toolStatusEnvelope(
+ result: unknown
+):
+ | { status: ToolTimelineEntryStatus; failure?: ToolFailureExplanation; value?: unknown }
+ | undefined {
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
+ const candidate = result as { status?: unknown; failure?: unknown; value?: unknown };
+ return candidate.status === 'error' || candidate.status === 'cancelled'
+ ? {
+ status: candidate.status as ToolTimelineEntryStatus,
+ failure: candidate.failure as ToolFailureExplanation | undefined,
+ ...('value' in candidate ? { value: candidate.value } : {}),
+ }
+ : undefined;
+}
+
+export const OpenHumanToolCall: ToolCallMessagePartComponent = props => {
+ const envelope = toolStatusEnvelope(props.result);
+ return (
+
+ );
+};
diff --git a/app/src/features/conversations/components/ChatThreadView.tsx b/app/src/features/conversations/components/ChatThreadView.tsx
index 1841ca7cac..e186bda89e 100644
--- a/app/src/features/conversations/components/ChatThreadView.tsx
+++ b/app/src/features/conversations/components/ChatThreadView.tsx
@@ -13,6 +13,7 @@ import {
import { Conversation, ConversationContent } from '../../../components/ai-elements';
import { useStickToBottom } from '../../../hooks/useStickToBottom';
+import { useCoreTranscriptProjection } from '../../../providers/useOpenHumanExternalStore';
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { persistReaction } from '../../../store/threadSlice';
@@ -196,10 +197,6 @@ export const ChatThreadView = forwardRef state.chatRuntime.toolTimelineByThread);
- const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread);
- const turnTranscriptsByThread = useAppSelector(
- state => state.chatRuntime.turnTranscriptsByThread
- );
const interruptedAssistantByThread = useAppSelector(
state => state.chatRuntime.interruptedAssistantByThread
);
@@ -347,14 +344,16 @@ export const ChatThreadView = forwardRef {
const anchors: Record = {};
const seen = new Set();
diff --git a/app/src/features/conversations/components/ChatToolParts.test.tsx b/app/src/features/conversations/components/ChatToolParts.test.tsx
index c2c131b78d..1aa5c2dd93 100644
--- a/app/src/features/conversations/components/ChatToolParts.test.tsx
+++ b/app/src/features/conversations/components/ChatToolParts.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import type { SubagentActivity } from '../../../store/chatRuntimeSlice';
@@ -13,7 +14,7 @@ const activity: SubagentActivity = {
};
describe('ChatToolParts', () => {
- it('renders a delegation with progress args and no result as running', () => {
+ it('renders a running delegation collapsed by default', async () => {
render(
{
expect(screen.getByText('running')).toBeInTheDocument();
expect(screen.getByText('Researcher')).toBeInTheDocument();
+ expect(screen.queryByText('Checking primary sources.')).not.toBeInTheDocument();
+ expect(screen.getByTestId('assistant-ui-subagent-call')).toHaveAttribute(
+ 'data-state',
+ 'closed'
+ );
+ await userEvent.click(screen.getByRole('button', { name: /Delegated to Researcher/i }));
expect(screen.getByText('Checking primary sources.')).toBeInTheDocument();
});
+ it('renders a failed delegation as failed, not as a completed one', () => {
+ // `SubagentActivity.status` carries `failed`, but a settled part was read
+ // as `running: false` and rendered with a success check — the transcript
+ // reported a failure as a success.
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByTestId('assistant-ui-subagent-call')).toHaveAttribute(
+ 'data-status',
+ 'failed'
+ );
+ expect(screen.getByText('failed')).toBeInTheDocument();
+ expect(screen.queryByText('running')).not.toBeInTheDocument();
+ });
+
+ it('keeps a completed delegation reading as completed', () => {
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByTestId('assistant-ui-subagent-call')).toHaveAttribute(
+ 'data-status',
+ 'completed'
+ );
+ expect(screen.queryByText('failed')).not.toBeInTheDocument();
+ });
+
+ it('keeps a still-running delegation running when the part has already settled', () => {
+ // The tool-call status and the delegation status are separate fields, so a
+ // settled part can still carry an in-flight activity. Hard-coding
+ // `running: false` for any settled part froze that row into a success.
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByText('running')).toBeInTheDocument();
+ });
+
+ it('does not show a success icon beside a cancelled tool', () => {
+ // The adapter forwards `cancelled` now, and the card gated its non-success
+ // icon on `error` alone — so the check icon sat next to the word
+ // "cancelled". `failed` still gates the failure-explanation block, which
+ // only an `error` carries.
+ const { container } = render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByText('cancelled')).toBeInTheDocument();
+ const card = screen.getByTestId('assistant-ui-tool-call');
+ expect(card.querySelector('.lucide-circle-x')).not.toBeNull();
+ expect(card.querySelector('.lucide-check')).toBeNull();
+ expect(container).toBeTruthy();
+ });
+
it('opens a group containing in-flight work on mount', () => {
render(
@@ -43,4 +149,68 @@ describe('ChatToolParts', () => {
expect(screen.getByText('live delegation')).toBeVisible();
});
+
+ it('renders ordinary tools with rich input and output on the assistant-ui surface', async () => {
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByTestId('assistant-ui-tool-call')).toHaveTextContent('Searched the web');
+ await userEvent.click(screen.getByRole('button', { name: /Searched the web/ }));
+ expect(screen.getByText(/Lean open conjectures/)).toBeInTheDocument();
+ expect(screen.queryByText('Query', { exact: true })).not.toBeInTheDocument();
+ expect(screen.getByText('Found 12 candidate problems')).toBeInTheDocument();
+ });
+
+ it('unwraps a single content field instead of showing a redundant title', async () => {
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: /Fetched from the web/ }));
+ expect(screen.getByRole('strong')).toHaveTextContent('Example Domain');
+ expect(screen.queryByText('Content', { exact: true })).not.toBeInTheDocument();
+ });
+
+ it('infers web search labels when a persisted tool name degraded to tool', () => {
+ render(
+ {}}
+ resume={() => {}}
+ respondToApproval={() => {}}
+ />
+ );
+
+ expect(screen.getByTestId('assistant-ui-tool-call')).toHaveTextContent('Searched the web');
+ expect(screen.getByTestId('assistant-ui-tool-call')).not.toHaveTextContent(/^Tool done$/);
+ });
});
diff --git a/app/src/features/conversations/components/ChatToolParts.tsx b/app/src/features/conversations/components/ChatToolParts.tsx
index 69c06ec5fa..1069e6477d 100644
--- a/app/src/features/conversations/components/ChatToolParts.tsx
+++ b/app/src/features/conversations/components/ChatToolParts.tsx
@@ -1,22 +1,15 @@
import type { ToolCallMessagePartComponent } from '@assistant-ui/react';
-import { CheckIcon, ChevronDownIcon, Loader2Icon, WorkflowIcon } from 'lucide-react';
import type { FC, PropsWithChildren } from 'react';
-import { cn } from '../../../components/assistant-ui/lib/utils';
import type { ThreadGroupPart } from '../../../components/assistant-ui/thread';
-import { ToolFallback } from '../../../components/assistant-ui/tool-fallback';
import {
ToolGroupContent,
ToolGroupRoot,
ToolGroupTrigger,
} from '../../../components/assistant-ui/tool-group';
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from '../../../components/assistant-ui/ui/collapsible';
import type { SubagentActivity } from '../../../store/chatRuntimeSlice';
-import { SubagentActivityBlock } from './SubagentActivityBlock';
+import { AssistantUiSubagentCall, isActiveSubagentStatus } from './AssistantUiSubagentCall';
+import { OpenHumanToolCall } from './AssistantUiToolCall';
function asSubagentActivity(value: unknown): SubagentActivity | undefined {
if (!value || typeof value !== 'object') return undefined;
@@ -36,7 +29,10 @@ function readSubagentState(
result: unknown
): { activity: SubagentActivity | undefined; running: boolean } {
const completed = asSubagentActivity(result);
- if (completed) return { activity: completed, running: false };
+ // A settled part carries the activity, but "settled" is not "succeeded":
+ // ask the activity's own status so a `failed` delegation is not rendered as
+ // a completed one.
+ if (completed) return { activity: completed, running: isActiveSubagentStatus(completed.status) };
const progress =
args && typeof args === 'object'
? asSubagentActivity((args as { progress?: unknown }).progress)
@@ -44,64 +40,33 @@ function readSubagentState(
return { activity: progress, running: result === undefined };
}
-/** Render a real OpenHuman `task` delegation using the existing activity view. */
+/** Adapt an assistant-ui `task` part onto the shared delegation card. */
export const SubagentCall: ToolCallMessagePartComponent = ({ args, result }) => {
const { activity, running } = readSubagentState(args, result);
const description = (args as { description?: string } | undefined)?.description;
- const name =
- activity?.displayName ??
- activity?.agentId ??
- (args as { subagent_type?: string } | undefined)?.subagent_type ??
- 'subagent';
-
+ const fallbackAgent = (args as { subagent_type?: string } | undefined)?.subagent_type;
+ const resolved = activity ?? {
+ taskId: 'pending-subagent',
+ agentId: fallbackAgent ?? 'subagent',
+ toolCalls: [],
+ };
return (
-
-
-
-
- Delegated to {name}
-
- {running ? (
-
-
- running
-
- ) : (
-
-
- {activity?.elapsedMs != null && (
- {(activity.elapsedMs / 1000).toFixed(1)}s
- )}
-
- )}
-
-
-
- {description && {description}
}
- {activity && }
-
-
+
);
};
-/** Route delegations to the rich renderer and ordinary tools to assistant-ui. */
+/** Route every call through an assistant-ui-native rich renderer. */
export const ChatToolFallback: ToolCallMessagePartComponent = props =>
- props.toolName === 'task' ? : ;
+ props.toolName === 'task' ? : ;
-/** Keep a tool group open while any contained call is still running. */
+/** Keep the assistant-ui tool cards visible; each card owns its detail collapse. */
export const ChatToolGroup: FC> = ({
group,
children,
}) => {
const running = group.status.type === 'running';
return (
-
+
{children}
diff --git a/app/src/features/conversations/components/PastTurnInsights.test.tsx b/app/src/features/conversations/components/PastTurnInsights.test.tsx
index ef0314b83b..31a5bb47cd 100644
--- a/app/src/features/conversations/components/PastTurnInsights.test.tsx
+++ b/app/src/features/conversations/components/PastTurnInsights.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
@@ -53,6 +53,7 @@ describe('PastTurnInsights', () => {
renderInStore( );
const subagents = screen.getByTestId('past-turn-subagents');
+ fireEvent.click(screen.getByTestId('assistant-ui-subagent-call').querySelector('button')!);
expect(subagents.textContent).toContain('child reasoning trail');
});
diff --git a/app/src/features/conversations/components/PastTurnInsights.tsx b/app/src/features/conversations/components/PastTurnInsights.tsx
index 28d2e5c6a2..6389ed70e5 100644
--- a/app/src/features/conversations/components/PastTurnInsights.tsx
+++ b/app/src/features/conversations/components/PastTurnInsights.tsx
@@ -1,7 +1,8 @@
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
+import { AssistantUiSubagentCall } from './AssistantUiSubagentCall';
import { ProcessingTranscriptView } from './ProcessingTranscriptView';
-import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock';
+import { ToolTimelineBlock } from './ToolTimelineBlock';
/**
* The collapsed process trail rendered above a PAST (settled) turn's answer on a
@@ -50,7 +51,7 @@ export function PastTurnInsights({
{formatTimelineEntry(entry).title}
-
+
))}
diff --git a/app/src/features/conversations/components/ProcessingTranscriptView.tsx b/app/src/features/conversations/components/ProcessingTranscriptView.tsx
index 4d2a680d01..b2ddf66801 100644
--- a/app/src/features/conversations/components/ProcessingTranscriptView.tsx
+++ b/app/src/features/conversations/components/ProcessingTranscriptView.tsx
@@ -35,7 +35,7 @@ export function ProcessingTranscriptView({
* 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
+ * Injected rather than imported because the assistant-ui delegation card 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
@@ -171,7 +171,7 @@ function ToolRow({
{/* 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 `` SIBLING under the `` (indented past the
- icon), not nested inside the label `` — SubagentActivityBlock
+ icon), not nested inside the label `` — the delegation card
renders a ``, and ``-inside-`` is invalid nesting. */}
{entry.subagent && renderSubagent ? (
diff --git a/app/src/features/conversations/components/SubagentActivityBlock.tsx b/app/src/features/conversations/components/SubagentActivityBlock.tsx
deleted file mode 100644
index a907f19351..0000000000
--- a/app/src/features/conversations/components/SubagentActivityBlock.tsx
+++ /dev/null
@@ -1,274 +0,0 @@
-import Badge, { type BadgeVariant } from '../../../components/ui/Badge';
-import WorktreeActions from '../../../components/worktree/WorktreeActions';
-import { useT } from '../../../lib/i18n/I18nContext';
-import type {
- SubagentActivity,
- ToolFailureExplanation,
- ToolTimelineEntryStatus,
-} from '../../../store/chatRuntimeSlice';
-import { basename } from '../../../utils/pathUtils';
-import { formatToolName, stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting';
-import { BubbleMarkdown } from './AgentMessageBubble';
-import { ToolFailureLines } from './ToolFailureLines';
-
-/**
- * Map a child tool-call status to the shared {@link Badge} variant. The label
- * itself comes from `useStatusTagLabel` below; keeping the two beside each
- * other is what stops a status reading "Failed" in a success-toned pill.
- */
-function statusTagVariant(status: ToolTimelineEntryStatus): BadgeVariant {
- switch (status) {
- case 'error':
- return 'danger';
- case 'running':
- case 'awaiting_user':
- return 'warning';
- case 'cancelled':
- return 'neutral';
- default:
- return 'success';
- }
-}
-
-/** Tone classes for a child tool-call row's bullet, keyed by lifecycle status. */
-function toolCallTone(status: ToolTimelineEntryStatus): string {
- if (status === 'running') return 'text-amber-700 dark:text-amber-300';
- if (status === 'success') return 'text-sage-700 dark:text-sage-300';
- return 'text-coral-700 dark:text-coral-300';
-}
-
-/**
- * Status pill for a tool-call row — a tinted "Done" / "Failed" / "Running"
- * tag instead of a bare ✓/✕ glyph, so the outcome reads at a glance. Built on
- * the shared {@link Badge} primitive, which publishes the tone as
- * `data-variant` for tests to assert instead of a Tailwind class string.
- */
-export function StatusTag({ status }: { status: ToolTimelineEntryStatus }) {
- const { t } = useT();
- const label =
- status === 'error'
- ? t('conversations.agentTaskInsights.failed')
- : status === 'running'
- ? t('conversations.agentTaskInsights.running')
- : status === 'cancelled'
- ? t('conversations.agentTaskInsights.cancelled')
- : status === 'awaiting_user'
- ? t('conversations.agentTaskInsights.awaitingUser')
- : t('conversations.agentTaskInsights.done');
- return (
-
- {label}
-
- );
-}
-
-/**
- * One child tool-call row in a sub-agent's inline activity. Shared by the
- * ordered transcript (interleaved with {@link ThoughtBlock}) and the flat
- * `toolCalls` fallback, so the row markup lives in exactly one place.
- */
-export function ToolCallRow({
- call,
-}: {
- call: {
- callId: string;
- toolName: string;
- status: ToolTimelineEntryStatus;
- elapsedMs?: number;
- iteration?: number;
- /** Server-computed human label; preferred over the client formatter. */
- displayName?: string;
- /** Server-computed contextual detail (path / recipient / query). */
- detail?: string;
- /** Structured why/next explanation for a FAILED child tool call (#4459). */
- failure?: ToolFailureExplanation;
- };
-}) {
- return (
-
-
-
- •
-
-
- {call.displayName ?? formatToolName(call.toolName)}
-
- {/* The contextual arg (path / recipient / query) can be long, so it
- truncates to a single line and absorbs the row's spare width — the
- full value stays available on hover — instead of wrapping into a
- multi-line box that knocks the name and status out of alignment. */}
- {call.detail ? (
-
- {call.detail}
-
- ) : null}
- {/* Status reads as a tinted "Done" / "Failed" / "Running" tag. */}
-
-
-
- {call.elapsedMs != null && call.status !== 'running' ? (
-
- {call.elapsedMs >= 1000
- ? `${(call.elapsedMs / 1000).toFixed(1)}s`
- : `${call.elapsedMs}ms`}
-
- ) : null}
-
- {call.status === 'error' && call.failure ? : null}
-
- );
-}
-
-/**
- * The agent's reasoning or visible narration, surfaced inline in the timeline
- * as quoted/italic prose at the position it streamed — so a thought shows up
- * wherever it occurred between tool calls. Shown directly (no "Thoughts"
- * heading, no collapse). Both `thinking` and `text` transcript items render
- * through here. Renders nothing for an all-whitespace delta so a half-streamed
- * item never flashes an empty quote.
- */
-export function ThoughtBlock({ text }: { text: string }) {
- // Drop any inline `… ` envelope the model emitted as
- // text — the call already shows as its own row. Keep the original newlines
- // (only trim the ends) so the markdown renderer can see headings, lists,
- // code fences and emphasis instead of flattening them to one plain line.
- const clean = stripToolCallEnvelopes(text).trim();
- if (!clean) return null;
- // Rendered through the shared `BubbleMarkdown` so a thought formats markdown
- // (bold, code, lists) — but scaled back to the original quiet thought look:
- // small (12px) and light/muted, not the larger, darker agent-bubble prose.
- // Descendant overrides on `.prose` beat the typography plugin's base sizing;
- // code keeps its accent colour so inline `tool_names` still read clearly.
- return (
-
-
-
- );
-}
-
-/**
- * Render the live activity of one running (or completed) sub-agent inside its
- * parent timeline row — the mode/dedicated-thread badge, the child iteration
- * counter, the final-run statistics, and the ordered transcript of child tool
- * calls interleaved with the agent's "Thoughts" (reasoning + narration).
- *
- * Kept as a sibling of the existing worker-thread / detail block so the
- * surrounding disclosure chevron + status pill behaviour is unaffected — this
- * component only renders when `subagent` is present on the entry, which is true
- * for any row produced by the `subagent_*` socket events from a current core.
- */
-export function SubagentActivityBlock({
- subagent,
- onView,
-}: {
- subagent: SubagentActivity;
- /** Opens the full-transcript drawer for this subagent. Omitted in
- * read-only contexts (e.g. a completed snapshot with no live driver). */
- onView?: () => void;
-}) {
- const { t } = useT();
- const headerBits: string[] = [];
- if (subagent.mode) headerBits.push(subagent.mode);
- if (subagent.dedicatedThread) headerBits.push(t('conversations.toolTimeline.workerThread'));
- if (subagent.childIteration != null) {
- if (subagent.childMaxIterations != null) {
- headerBits.push(
- `${t('conversations.toolTimeline.turn')} ${subagent.childIteration}/${subagent.childMaxIterations}`
- );
- } else {
- headerBits.push(`${t('conversations.toolTimeline.step')} ${subagent.childIteration}`);
- }
- } else if (subagent.iterations != null) {
- headerBits.push(
- subagent.iterations === 1
- ? `${subagent.iterations} ${t('chat.turn')}`
- : `${subagent.iterations} ${t('chat.turns')}`
- );
- }
- if (subagent.elapsedMs != null) {
- headerBits.push(
- subagent.elapsedMs >= 1000
- ? `${(subagent.elapsedMs / 1000).toFixed(1)}s`
- : `${subagent.elapsedMs}ms`
- );
- }
-
- // The ordered transcript drives the inline activity: child tool-call rows
- // and the agent's "Thoughts" (reasoning + visible narration) render in the
- // exact order they streamed, so each thought appears wherever it occurred
- // between tool calls. Falls back to the flat tool-call list when the prose
- // transcript is absent (e.g. a rehydrated/interrupted snapshot).
- const transcript = subagent.transcript ?? [];
-
- return (
-
- {headerBits.length > 0 ? (
-
- {headerBits.map(bit => (
-
- {bit}
-
- ))}
-
- ) : null}
- {transcript.length > 0 ? (
-
- {transcript.map((item, i) =>
- item.kind === 'tool' ? (
-
- ) : (
-
- )
- )}
-
- ) : subagent.toolCalls.length > 0 ? (
-
- {subagent.toolCalls.map(call => (
-
- ))}
-
- ) : null}
- {subagent.worktreePath ? (
-
-
- {t('worktree.label')}
-
- {basename(subagent.worktreePath)}
-
-
- {subagent.isDirty ? t('worktree.dirty') : t('worktree.clean')}
-
- {subagent.changedFiles && subagent.changedFiles.length > 0 ? (
-
- {subagent.changedFiles.length}{' '}
- {subagent.changedFiles.length === 1
- ? t('worktree.changedFile')
- : t('worktree.changedFiles')}
-
- ) : null}
-
-
-
- ) : null}
- {onView ? (
-
- ) : null}
-
- );
-}
diff --git a/app/src/features/conversations/components/SubagentDrawer.tsx b/app/src/features/conversations/components/SubagentDrawer.tsx
index 328c7feaf3..7df0210f1a 100644
--- a/app/src/features/conversations/components/SubagentDrawer.tsx
+++ b/app/src/features/conversations/components/SubagentDrawer.tsx
@@ -1,7 +1,7 @@
import createDebug from 'debug';
import { type ReactNode, useEffect, useState } from 'react';
-import Badge from '../../../components/ui/Badge';
+import Badge, { type BadgeVariant } from '../../../components/ui/Badge';
import Button from '../../../components/ui/Button';
import { SheetContent, SheetRoot, SheetTitle } from '../../../components/ui/Sheet';
import { useT } from '../../../lib/i18n/I18nContext';
@@ -14,15 +14,30 @@ import type {
import type { ThreadMessage } from '../../../types/thread';
import { stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting';
import { BubbleMarkdown } from './AgentMessageBubble';
-import {
- formatElapsed,
- subagentStatusVariant,
- SubagentToolCallRow,
- useSubagentStatusLabel,
-} from './SubagentToolCallRow';
+import { AssistantUiToolCallCard } from './AssistantUiToolCall';
const log = createDebug('app:conversations:subagent-drawer');
+function formatElapsed(ms: number): string {
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
+}
+
+function subagentStatusVariant(status: ToolTimelineEntryStatus | undefined): BadgeVariant {
+ if (status === 'success') return 'success';
+ if (status === 'error') return 'danger';
+ if (status === 'cancelled') return 'neutral';
+ return 'warning';
+}
+
+function useSubagentStatusLabel(status: ToolTimelineEntryStatus | undefined): string {
+ const { t } = useT();
+ if (status === 'success') return t('conversations.subagent.statusCompleted');
+ if (status === 'error') return t('conversations.subagent.statusFailed');
+ if (status === 'cancelled') return t('conversations.subagent.statusCancelled');
+ if (status === 'awaiting_user') return t('conversations.subagent.statusAwaitingUser');
+ return t('conversations.subagent.statusRunning');
+}
+
/**
* Rebuild a renderable transcript from a worker sub-thread's persisted
* messages so a delegation can be reopened from memory after its live
@@ -367,7 +382,16 @@ export function SubagentDrawer({
return (
-
+
);
})}
diff --git a/app/src/features/conversations/components/SubagentToolCallRow.tsx b/app/src/features/conversations/components/SubagentToolCallRow.tsx
deleted file mode 100644
index a1da25ec8b..0000000000
--- a/app/src/features/conversations/components/SubagentToolCallRow.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-import createDebug from 'debug';
-import { useState } from 'react';
-
-import Badge, { type BadgeVariant } from '../../../components/ui/Badge';
-import {
- CollapsibleContent,
- CollapsibleRoot,
- CollapsibleTrigger,
-} from '../../../components/ui/Collapsible';
-import { useT } from '../../../lib/i18n/I18nContext';
-import type {
- SubagentTranscriptItem,
- ToolTimelineEntryStatus,
-} from '../../../store/chatRuntimeSlice';
-import { ToolFailureLines } from './ToolFailureLines';
-
-const log = createDebug('app:conversations:subagent-tool-call');
-
-/** Human-readable elapsed time for a sub-agent run or one of its tool calls. */
-export function formatElapsed(ms: number): string {
- return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
-}
-
-/**
- * Map a sub-agent lifecycle status to the shared {@link Badge} variant used
- * for it everywhere in the conversation panel, so the drawer header pill, the
- * transcript tool rows and the inline timeline all read as one system. Kept in
- * one place because a status that renders `warning` in one surface and
- * `neutral` in another is a bug nobody notices until a screenshot diff.
- */
-export function subagentStatusVariant(status: ToolTimelineEntryStatus | undefined): BadgeVariant {
- switch (status) {
- case 'success':
- return 'success';
- case 'error':
- return 'danger';
- case 'cancelled':
- return 'neutral';
- default:
- // running / awaiting_user — in flight, needs attention.
- return 'warning';
- }
-}
-
-/** Localised label for a sub-agent lifecycle status. */
-export function useSubagentStatusLabel(status: ToolTimelineEntryStatus | undefined): string {
- const { t } = useT();
- switch (status) {
- case 'success':
- return t('conversations.subagent.statusCompleted');
- case 'error':
- return t('conversations.subagent.statusFailed');
- case 'cancelled':
- return t('conversations.subagent.statusCancelled');
- case 'awaiting_user':
- return t('conversations.subagent.statusAwaitingUser');
- default:
- return t('conversations.subagent.statusRunning');
- }
-}
-
-type SubagentToolItem = Extract;
-
-/**
- * Pretty-print a tool's input arguments for display. Objects/arrays are
- * rendered as indented JSON; a string is shown verbatim. Returns `null` when
- * there are no arguments to show (e.g. a tool called with no input, or a
- * transcript reopened from memory where args weren't persisted).
- */
-export function formatArgs(args: unknown): string | null {
- if (args == null) return null;
- if (typeof args === 'string') return args.length > 0 ? args : null;
- try {
- return JSON.stringify(args, null, 2);
- } catch {
- return String(args);
- }
-}
-
-const DETAIL_PRE =
- 'max-h-60 overflow-auto whitespace-pre-wrap wrap-break-word rounded bg-surface px-2 py-1.5 ' +
- 'font-mono text-[11px] leading-relaxed text-content-secondary';
-const DETAIL_LABEL = 'mb-1 text-[10px] font-semibold uppercase tracking-wide text-content-faint';
-
-/**
- * One child tool call in the drawer transcript, expandable to reveal exactly
- * *what happened*: the input arguments the sub-agent passed and the raw output
- * the tool returned. Collapsed by default to keep the transcript scannable;
- * the disclosure is only enabled once there's detail to reveal (args present,
- * or the call completed with a captured result). Reopened-from-memory
- * transcripts carry no args/result, so those rows stay non-expandable.
- *
- * The disclosure is the shared Radix {@link CollapsibleRoot} rather than a
- * hand-rolled `useState` + conditional render, so the trigger carries real
- * `aria-expanded` / `aria-controls` wiring instead of an ad-hoc attribute.
- */
-export function SubagentToolCallRow({ item }: { item: SubagentToolItem }) {
- const { t } = useT();
- const [expanded, setExpanded] = useState(false);
-
- const statusLabel = useSubagentStatusLabel(item.status);
- const argsText = formatArgs(item.args);
- const hasOutput = item.result != null;
- const expandable = argsText != null || hasOutput;
-
- return (
- {
- log('tool-call %s expanded=%s', item.toolName, next);
- setExpanded(next);
- }}
- className="rounded-md border border-line bg-surface-muted text-xs"
- data-testid="subagent-drawer-tool-call">
-
- {expandable ? (
-
- {expanded ? '▾' : '▸'}
-
- ) : (
-
- )}
- 🔧
- {item.toolName}
-
- {statusLabel}
-
- {item.elapsedMs != null && item.status !== 'running' ? (
-
- {formatElapsed(item.elapsedMs)}
-
- ) : null}
-
- {item.status === 'error' && item.failure ? (
-
-
-
- ) : null}
-
- {argsText != null ? (
-
- {t('conversations.subagent.input')}
- {argsText}
-
- ) : null}
- {hasOutput ? (
-
- {t('conversations.subagent.output')}
-
- {item.result && item.result.length > 0
- ? item.result
- : t('conversations.subagent.noOutput')}
-
-
- ) : null}
-
-
- );
-}
diff --git a/app/src/features/conversations/components/ToolTimelineBlock.tsx b/app/src/features/conversations/components/ToolTimelineBlock.tsx
index 61a8e54e68..4975148cc4 100644
--- a/app/src/features/conversations/components/ToolTimelineBlock.tsx
+++ b/app/src/features/conversations/components/ToolTimelineBlock.tsx
@@ -15,8 +15,8 @@ import type {
import { formatTimelineEntry, stripToolCallEnvelopes } from '../../../utils/toolTimelineFormatting';
import { parseWorkerThreadRef } from '../utils/workerThreadRef';
import { agentNameTone, AgentTimelineRail } from './AgentTimelineRail';
+import { AssistantUiSubagentCall, isActiveSubagentStatus } from './AssistantUiSubagentCall';
import { ProcessingTranscriptView } from './ProcessingTranscriptView';
-import { SubagentActivityBlock } from './SubagentActivityBlock';
import {
coalesceTimelineEntries,
normalizeToolBody,
@@ -25,13 +25,6 @@ import {
} from './toolTimelineRows';
import { WorkerThreadRefCard } from './WorkerThreadRefCard';
-/**
- * Re-exported so the historical import path keeps resolving. The component
- * itself moved to `./SubagentActivityBlock` when this file was split; it is a
- * pure move, no behaviour changed.
- */
-export { SubagentActivityBlock } from './SubagentActivityBlock';
-
/** Tail of the parent's in-flight response shown in the processing panel. */
const RESPONSE_PREVIEW_CHARS = 320;
@@ -426,8 +419,9 @@ export function ToolTimelineBlock({
transcript={transcript}
entries={ordered}
renderSubagent={subagent => (
- onViewSubagent(subagent) : undefined}
/>
)}
@@ -536,8 +530,9 @@ export function ToolTimelineBlock({
) : null}
{subagent ? (
- onViewSubagent(subagent) : undefined}
/>
) : null}
diff --git a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx
index 9208cd39f5..fd0f5fd301 100644
--- a/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx
+++ b/app/src/features/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { describe, expect, it, vi } from 'vitest';
@@ -11,6 +11,10 @@ function renderPanel(ui: React.ReactNode) {
return render({ui} );
}
+function openFirstSubagent(): void {
+ fireEvent.click(screen.getAllByTestId('assistant-ui-subagent-call')[0].querySelector('button')!);
+}
+
const fetchEntry = (id: string, url: string): ToolTimelineEntry => ({
id,
name: 'web_fetch',
@@ -129,7 +133,8 @@ describe('AgentProcessSourcePanel', () => {
onClose={() => {}}
/>
);
- // The subagent activity renders, but with no onView → no button.
+ // The assistant-ui delegation is collapsed until the reviewer opens it.
+ openFirstSubagent();
expect(screen.getByTestId('subagent-activity')).toBeInTheDocument();
expect(screen.queryByTestId('subagent-view-processing')).toBeNull();
});
@@ -187,6 +192,7 @@ describe('AgentProcessSourcePanel', () => {
// …and the sub-agent's full activity (its thoughts) shows in the deep-dive,
// with no redundant "view full processing" button (no onView).
expect(screen.getByTestId('agent-source-subagent')).toBeInTheDocument();
+ openFirstSubagent();
const activity = screen.getByTestId('subagent-activity');
expect(activity.textContent).toContain('planning the search');
expect(screen.queryByTestId('subagent-view-processing')).toBeNull();
@@ -222,6 +228,7 @@ describe('AgentProcessSourcePanel', () => {
// Header shows the step's label, not the generic title.
expect(screen.getByText('Researching')).toBeInTheDocument();
// Only the scoped step's activity renders…
+ openFirstSubagent();
expect(screen.getByTestId('subagent-activity').textContent).toContain('scoped thought');
// …and the whole-run transcript / other steps do NOT.
expect(screen.queryByTestId('processing-transcript')).toBeNull();
diff --git a/app/src/features/conversations/components/__tests__/SubagentDrawer.test.tsx b/app/src/features/conversations/components/__tests__/SubagentDrawer.test.tsx
index e74f9a25ac..f885e4bdbb 100644
--- a/app/src/features/conversations/components/__tests__/SubagentDrawer.test.tsx
+++ b/app/src/features/conversations/components/__tests__/SubagentDrawer.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -49,7 +49,7 @@ describe('SubagentDrawer', () => {
// thinking → text → tool → text — i.e. the tool sits between the two
// text blocks, not in a separate section.
const thinking = screen.getByTestId('subagent-transcript-thinking');
- const tool = screen.getByTestId('subagent-drawer-tool-call');
+ const tool = screen.getByTestId('assistant-ui-tool-call');
const texts = screen.getAllByTestId('subagent-transcript-text');
expect(texts).toHaveLength(2);
@@ -60,7 +60,7 @@ describe('SubagentDrawer', () => {
expect(order(tool)).toBeLessThan(order(texts[1]));
expect(thinking.textContent).toContain('comparing the two sources');
- expect(tool.textContent).toContain('web_search');
+ expect(tool.textContent).toContain('Searched the web');
expect(tool.textContent).toContain('1.2s');
expect(texts[1].textContent).toContain('The answer is');
});
@@ -178,7 +178,7 @@ describe('SubagentDrawer', () => {
await waitFor(() =>
expect(screen.getByTestId('subagent-parent-prompt').textContent).toContain('Research Q3')
);
- expect(screen.getByTestId('subagent-drawer-tool-call').textContent).toContain('web_search');
+ expect(screen.getByTestId('assistant-ui-tool-call').textContent).toContain('Searched the web');
expect(screen.getByTestId('subagent-transcript-text').textContent).toContain(
'Revenue grew 18%'
);
@@ -292,15 +292,15 @@ describe('SubagentDrawer', () => {
);
// Collapsed by default — neither input nor output is rendered yet.
- expect(screen.queryByTestId('subagent-tool-call-input')).toBeNull();
- expect(screen.queryByTestId('subagent-tool-call-output')).toBeNull();
+ expect(screen.queryByTestId('assistant-ui-tool-input')).toBeNull();
+ expect(screen.queryByTestId('assistant-ui-tool-output')).toBeNull();
- await userEvent.click(screen.getByTestId('subagent-tool-call-toggle'));
+ await userEvent.click(within(screen.getByTestId('assistant-ui-tool-call')).getByRole('button'));
- expect(screen.getByTestId('subagent-tool-call-input').textContent).toContain(
+ expect(screen.getByTestId('assistant-ui-tool-input').textContent).toContain(
'Q3 revenue drivers'
);
- expect(screen.getByTestId('subagent-tool-call-output').textContent).toContain(
+ expect(screen.getByTestId('assistant-ui-tool-output').textContent).toContain(
'Found 3 results about revenue.'
);
});
@@ -312,8 +312,8 @@ describe('SubagentDrawer', () => {
render(
{}} />
);
- await userEvent.click(screen.getByTestId('subagent-tool-call-toggle'));
- expect(screen.getByTestId('subagent-tool-call-output').textContent?.toLowerCase()).toContain(
+ await userEvent.click(within(screen.getByTestId('assistant-ui-tool-call')).getByRole('button'));
+ expect(screen.getByTestId('assistant-ui-tool-output').textContent?.toLowerCase()).toContain(
'no output'
);
});
@@ -326,7 +326,7 @@ describe('SubagentDrawer', () => {
render(
{}} />
);
- const rows = screen.getAllByTestId('subagent-drawer-tool-call');
+ const rows = screen.getAllByTestId('assistant-ui-tool-call');
expect(rows[0].textContent?.toLowerCase()).toContain('cancelled');
expect(rows[0].textContent?.toLowerCase()).not.toContain('failed');
expect(rows[1].textContent?.toLowerCase()).toContain('awaiting');
@@ -339,9 +339,9 @@ describe('SubagentDrawer', () => {
render(
{}} />
);
- const toggle = screen.getByTestId('subagent-tool-call-toggle') as HTMLButtonElement;
- expect(toggle.disabled).toBe(true);
- expect(screen.queryByTestId('subagent-tool-call-input')).toBeNull();
- expect(screen.queryByTestId('subagent-tool-call-output')).toBeNull();
+ const toggle = within(screen.getByTestId('assistant-ui-tool-call')).getByRole('button');
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByTestId('assistant-ui-tool-input')).toBeNull();
+ expect(screen.queryByTestId('assistant-ui-tool-output')).toBeNull();
});
});
diff --git a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
index 19e13fb219..4c68fa44cd 100644
--- a/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
+++ b/app/src/features/conversations/components/__tests__/ToolTimelineBlock.test.tsx
@@ -4,8 +4,19 @@ import { Provider } from 'react-redux';
import { describe, expect, it, vi } from 'vitest';
import { store } from '../../../../store';
-import type { ToolTimelineEntry } from '../../../../store/chatRuntimeSlice';
-import { SubagentActivityBlock, ToolTimelineBlock } from '../ToolTimelineBlock';
+import type { SubagentActivity, ToolTimelineEntry } from '../../../../store/chatRuntimeSlice';
+import { AssistantUiSubagentCall } from '../AssistantUiSubagentCall';
+import { ToolTimelineBlock } from '../ToolTimelineBlock';
+
+function SubagentActivityBlock({
+ subagent,
+ onView,
+}: {
+ subagent: SubagentActivity;
+ onView?: () => void;
+}) {
+ return ;
+}
// #1122 — guards the parent-thread live subagent rendering. The block
// always expands subagent rows so the activity stays visible while the
@@ -16,6 +27,34 @@ function renderInStore(ui: React.ReactNode) {
}
describe('SubagentActivityBlock', () => {
+ it('derives its lifecycle from the activity when no running prop is passed', () => {
+ // Most call sites (AgentProcessSourcePanel, PastTurnInsights, this block)
+ // pass no `running` prop at all. The old `running = false` default reported
+ // an in-flight delegation as finished, with a success check.
+ renderInStore(
+
+ );
+
+ expect(screen.getByText('running')).toBeInTheDocument();
+ });
+
+ it('marks a failed delegation as failed rather than complete', () => {
+ renderInStore(
+
+ );
+
+ expect(screen.getByTestId('assistant-ui-subagent-call')).toHaveAttribute(
+ 'data-status',
+ 'failed'
+ );
+ expect(screen.getByText('failed')).toBeInTheDocument();
+ expect(screen.queryByText('running')).not.toBeInTheDocument();
+ });
+
it('renders mode + dedicated-thread + child-turn pills', () => {
renderInStore(
{
}}
/>
);
- const calls = screen.getAllByTestId('subagent-tool-call');
+ const calls = screen.getAllByTestId('assistant-ui-tool-call');
expect(calls).toHaveLength(3);
// Human labels + timing, with status as a tinted "Done" / "Failed" /
// "Running" tag instead of a bare ✓/✕ glyph or the raw lowercase word.
- expect(calls[0].textContent).toContain('Searching the web');
- expect(calls[0].textContent).toContain('Done');
+ expect(calls[0].textContent).toContain('Searched the web');
+ expect(calls[0].textContent?.toLowerCase()).toContain('done');
expect(calls[0].textContent).toContain('312ms');
expect(calls[1].textContent).toContain('Composio Execute');
- expect(calls[1].textContent).toContain('Running');
+ expect(calls[1].textContent?.toLowerCase()).toContain('running');
expect(calls[1].textContent).not.toContain('·t2');
expect(calls[2].textContent).toContain('Reading file');
- expect(calls[2].textContent).toContain('Failed');
+ expect(calls[2].textContent?.toLowerCase()).toContain('failed');
expect(calls[2].textContent).toContain('50ms');
});
+ it('renders subagent web output as Markdown instead of raw JSON', async () => {
+ renderInStore(
+
+ );
+
+ expect(screen.getByText('Searched the web')).toBeInTheDocument();
+ const call = screen.getByTestId('assistant-ui-tool-call');
+ await userEvent.click(within(call).getByRole('button'));
+ expect(screen.getByTestId('assistant-ui-tool-output')).toHaveTextContent('Formal Conjectures');
+ expect(screen.getByRole('strong')).toHaveTextContent('Formal Conjectures');
+ expect(screen.queryByText(/"content"/)).not.toBeInTheDocument();
+ });
+
+ it('infers a descriptive search label for a degraded subagent tool name', () => {
+ renderInStore(
+
+ );
+
+ expect(screen.getByTestId('assistant-ui-tool-call')).toHaveTextContent('Searched the web');
+ });
+
it('labels cancelled / awaiting-user calls distinctly (not the green "Done" pill)', () => {
renderInStore(
{
}}
/>
);
- const calls = screen.getAllByTestId('subagent-tool-call');
+ const calls = screen.getAllByTestId('assistant-ui-tool-call');
expect(calls).toHaveLength(2);
// A cancelled / awaiting-user call must NOT read as a successful "Done" step.
- expect(calls[0].textContent).toContain('Cancelled');
- expect(calls[0].textContent).not.toContain('Done');
- expect(calls[1].textContent).toContain('Awaiting input');
- expect(calls[1].textContent).not.toContain('Done');
+ expect(calls[0].textContent?.toLowerCase()).toContain('cancelled');
+ expect(calls[0].textContent?.toLowerCase()).not.toContain('done');
+ expect(calls[1].textContent?.toLowerCase()).toContain('awaiting input');
+ expect(calls[1].textContent?.toLowerCase()).not.toContain('done');
});
it('prefers the server-supplied label + contextual detail for a child tool call', () => {
@@ -133,7 +222,7 @@ describe('SubagentActivityBlock', () => {
}}
/>
);
- const row = screen.getByTestId('subagent-tool-call');
+ const row = screen.getByTestId('assistant-ui-tool-call');
expect(row.textContent).toContain('Reading messages');
expect(row.textContent).toContain('steven@gmail.com');
// Never the raw snake_case slug.
@@ -182,8 +271,8 @@ describe('SubagentActivityBlock', () => {
// Order is preserved: thought → tool → thought.
expect(rows[0]).toHaveAttribute('data-testid', 'subagent-thought');
expect(rows[0].textContent).toContain('I should search the web first');
- expect(rows[1]).toHaveAttribute('data-testid', 'subagent-tool-call');
- expect(rows[1].textContent).toContain('Searching the web');
+ expect(rows[1]).toHaveAttribute('data-testid', 'assistant-ui-tool-call');
+ expect(rows[1].textContent).toContain('Searched the web');
expect(rows[2]).toHaveAttribute('data-testid', 'subagent-thought');
expect(rows[2].textContent).toContain('Found three relevant results');
});
@@ -860,7 +949,7 @@ describe('ToolTimelineBlock — coalescing repeated rows', () => {
});
describe('ToolTimelineBlock — subagent rendering', () => {
- it('expands a subagent row even without prompt detail and shows child tool calls', () => {
+ it('shows child tool calls after the collapsed subagent row is opened', () => {
const entry: ToolTimelineEntry = {
id: 'tid:subagent:sub-1:researcher',
name: 'subagent:researcher',
@@ -878,7 +967,11 @@ describe('ToolTimelineBlock — subagent rendering', () => {
};
renderInStore( );
- const calls = screen.getAllByTestId('subagent-tool-call');
+ const subagent = screen.getByTestId('assistant-ui-subagent-call');
+ const trigger = within(subagent).getByRole('button');
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(trigger);
+ const calls = screen.getAllByTestId('assistant-ui-tool-call');
expect(calls).toHaveLength(1);
expect(calls[0].textContent).toContain('Searching the web');
expect(screen.getByTestId('subagent-activity').textContent).toContain('turn 1/5');
@@ -983,7 +1076,7 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
},
];
- it('collapses finished steps to a "View details" link but keeps the running step expanded inline', () => {
+ it('collapses finished steps to a link and keeps the running delegation card inline', () => {
const onViewDetails = vi.fn();
renderInStore( );
@@ -991,8 +1084,12 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
const links = screen.getAllByTestId('view-details');
expect(links).toHaveLength(1);
- // The currently-running sub-agent stays expanded inline in the main UI
- // (its activity is visible) — and shows no "View details" link itself.
+ // The running delegation remains inline but its assistant-ui disclosure is
+ // collapsed by default like every other delegation card.
+ const subagent = screen.getByTestId('assistant-ui-subagent-call');
+ const trigger = within(subagent).getByRole('button');
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(trigger);
const activity = screen.getByTestId('subagent-activity');
expect(activity.textContent).toContain('pondering');
// The finished step SUCCEEDED, so its raw output is no longer duplicated
@@ -1035,7 +1132,9 @@ describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => {
it('still expands inline (no compact link) when onViewDetails is omitted (panel mode)', () => {
renderInStore( );
- // Panel/expandable path: sub-agent activity is shown, no "View details" link.
+ const subagent = screen.getByTestId('assistant-ui-subagent-call');
+ fireEvent.click(within(subagent).getByRole('button'));
+ // Panel path uses the same delegation card, with no compact details link.
expect(screen.getByTestId('subagent-activity')).toBeInTheDocument();
expect(screen.queryByTestId('view-details')).toBeNull();
});
@@ -1348,19 +1447,23 @@ describe('ToolTimelineBlock — sub-agent activity survives 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');
+ const subagent = screen.getByTestId('assistant-ui-subagent-call');
+ fireEvent.click(within(subagent).getByRole('button'));
+ const calls = screen.getAllByTestId('assistant-ui-tool-call');
expect(calls).toHaveLength(2);
- expect(calls[0].textContent).toContain('Searching the web');
- expect(calls[0].textContent).toContain('Done');
+ expect(calls[0].textContent).toContain('Searched the web');
+ expect(calls[0].textContent?.toLowerCase()).toContain('done');
// Human label, not the raw `web_fetch` slug.
expect(calls[1].textContent).toContain('Fetching');
- expect(calls[1].textContent).toContain('Running');
+ expect(calls[1].textContent?.toLowerCase()).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);
+ const subagent = screen.getByTestId('assistant-ui-subagent-call');
+ fireEvent.click(within(subagent).getByRole('button'));
+ expect(screen.getAllByTestId('assistant-ui-tool-call')).toHaveLength(2);
});
// The nested child run must live INSIDE the windowed viewport, and must not
diff --git a/app/src/features/conversations/derived/derivedRestore.render.test.tsx b/app/src/features/conversations/derived/derivedRestore.render.test.tsx
index 02490e8ec1..1d7a6e1009 100644
--- a/app/src/features/conversations/derived/derivedRestore.render.test.tsx
+++ b/app/src/features/conversations/derived/derivedRestore.render.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
@@ -73,6 +73,14 @@ describe('derived transcript restore (mapper → PastTurnInsights)', () => {
expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0);
// The sub-agent's own reasoning trail renders beneath.
const subagents = screen.getByTestId('past-turn-subagents');
+ // Assert the trigger before clicking: a bare `querySelector('button')!`
+ // throws an opaque TypeError at the click, pointing the stack at the click
+ // rather than at the element that was never rendered.
+ const subagentTrigger = screen
+ .getByTestId('assistant-ui-subagent-call')
+ .querySelector('button');
+ expect(subagentTrigger).not.toBeNull();
+ fireEvent.click(subagentTrigger as HTMLElement);
expect(subagents.textContent).toContain('child reasoning trail');
});
});
diff --git a/app/src/features/conversations/derived/mapDisplayItems.ts b/app/src/features/conversations/derived/mapDisplayItems.ts
index 1d5c3233ee..e3dab1b56f 100644
--- a/app/src/features/conversations/derived/mapDisplayItems.ts
+++ b/app/src/features/conversations/derived/mapDisplayItems.ts
@@ -4,7 +4,7 @@
* models, keyed by producing `requestId` — the exact shapes
* `fetchAndHydrateTurnHistory` produces from the legacy `turn_state_history`
* snapshot ring, so `PastTurnInsights` / `ProcessingTranscriptView` /
- * `ToolTimelineBlock` / `SubagentActivityBlock` are reused unchanged.
+ * assistant-ui tool and delegation cards are reused unchanged.
*
* Division of labour (matches how `turnTimelinesByThread` / `PastTurnInsights`
* anchor today):
@@ -136,7 +136,7 @@ function stringifyArgs(args: unknown): string | undefined {
* Build a {@link SubagentActivity} from a `subagent` display item's nested
* items. The nested vocabulary (reasoning / assistantMessage / toolCall)
* projects onto the sub-agent transcript (`thinking` / `text` / `tool`) plus a
- * flat `toolCalls` list — exactly what `SubagentActivityBlock` reads.
+ * flat `toolCalls` list — exactly what the assistant-ui delegation card reads.
*/
function buildSubagentActivity(id: string, items: DerivedDisplayItem[]): SubagentActivity {
const toolCalls: SubagentToolCallEntry[] = [];
diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx
index 79db6e27bc..746324259d 100644
--- a/app/src/pages/__tests__/Conversations.render.test.tsx
+++ b/app/src/pages/__tests__/Conversations.render.test.tsx
@@ -25,7 +25,6 @@ import chatRuntimeReducer, {
setInferenceStatusForThread,
setStreamingAssistantForThread,
setToolTimelineForThread,
- setTurnTimelinesForThread,
} from '../../store/chatRuntimeSlice';
import layoutReducer from '../../store/layoutSlice';
import socketReducer from '../../store/socketSlice';
@@ -76,6 +75,15 @@ vi.mock('../../services/api/threadApi', () => ({
getThreadMessages: mockGetThreadMessages,
getTurnState: vi.fn().mockResolvedValue(null),
getTurnStateHistory: vi.fn().mockResolvedValue([]),
+ getDerivedTranscript: vi
+ .fn()
+ .mockResolvedValue({
+ threadId: 'none',
+ items: [],
+ total: 0,
+ hasMore: false,
+ hasTranscript: false,
+ }),
getTaskBoard: vi
.fn()
.mockResolvedValue({ threadId: 't-1', cards: [], updatedAt: '2026-05-04T10:00:00Z' }),
@@ -623,6 +631,15 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
'Long agent output with enough structure to prefer a text view.'
);
expect(screen.getByText('Can you summarize this?')).toBeInTheDocument();
+ // Message rows must retain their measured layout/paint while off-screen.
+ // `content-visibility:auto` plus a guessed intrinsic height makes WebKit
+ // reveal/re-size rows as they cross the viewport, producing scroll flicker.
+ const assistantRoot = screen.getByTestId('agent-message');
+ const userRoot = document.querySelector('[data-slot="aui_user-message-root"]');
+ expect(assistantRoot.className).not.toContain('content-visibility');
+ expect(userRoot?.className).not.toContain('content-visibility');
+ expect(assistantRoot.className).not.toContain('contain-intrinsic-size');
+ expect(userRoot?.className).not.toContain('contain-intrinsic-size');
});
it("renders a past turn's process trail above the answer it produced (Phase 5)", async () => {
@@ -666,9 +683,18 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length });
- let store: ReturnType | undefined;
+ vi.mocked(threadApi.getDerivedTranscript).mockResolvedValueOnce({
+ threadId: thread.id,
+ items: [
+ { kind: 'toolCall', callId: 'tc-1', name: 'read_file', status: 'success' },
+ { kind: 'turnBoundary', requestId: 'req-1' },
+ ],
+ total: 2,
+ hasMore: false,
+ hasTranscript: true,
+ });
await act(async () => {
- store = await renderConversations({
+ await renderConversations({
thread: {
...selectedThreadState(thread),
messagesByThreadId: { [thread.id]: messages },
@@ -678,24 +704,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});
- // No past-turn tool call before hydration.
- expect(screen.queryByText(/read_file/)).not.toBeInTheDocument();
-
- // Hydrate the older turn's timeline (as fetchAndHydrateTurnHistory would).
- await act(async () => {
- store!.dispatch(
- setTurnTimelinesForThread({
- threadId: thread.id,
- timelines: {
- 'req-1': [{ id: 'tc-1', name: 'read_file', round: 0, seq: 0, status: 'success' }],
- },
- })
- );
- });
-
- // The past turn's tool call is projected into assistant-ui exactly once.
- fireEvent.click(await screen.findByRole('button', { name: /1 tool call/ }));
- expect(await screen.findByText('read_file')).toBeInTheDocument();
+ // The past turn's core transcript is projected into assistant-ui exactly once.
+ expect(await screen.findByTestId('assistant-ui-tool-call')).toHaveTextContent('Read File');
});
it('keeps assistant message copy available through assistant-ui', async () => {
@@ -980,7 +990,13 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// The send cleared the composer; with an empty composer mid-send the Send
// button morphs into the Stop button, so there is no Send affordance left
// to fire a duplicate send.
- expect(screen.getByRole('button', { name: 'Stop generating' })).toBeInTheDocument();
+ const stopButton = screen.getByRole('button', { name: 'Stop generating' });
+ expect(stopButton).toBeInTheDocument();
+ expect(stopButton).toHaveClass(
+ 'bg-primary-500',
+ 'text-content-inverted',
+ 'hover:bg-primary-600'
+ );
expect(screen.queryByRole('button', { name: 'Send message' })).not.toBeInTheDocument();
resolveSend?.();
});
diff --git a/app/src/providers/AssistantUiRuntimeProvider.tsx b/app/src/providers/AssistantUiRuntimeProvider.tsx
index b35c7ba023..d03eec2c9f 100644
--- a/app/src/providers/AssistantUiRuntimeProvider.tsx
+++ b/app/src/providers/AssistantUiRuntimeProvider.tsx
@@ -8,15 +8,11 @@ import { useOpenHumanExternalStore } from './useOpenHumanExternalStore';
const debug = debugFactory('openhuman:assistant-ui');
/**
- * Mounts assistant-ui's runtime over the existing Redux state, scoped to ONE
- * thread.
+ * Mounts assistant-ui's runtime over one OpenHuman thread.
*
- * This is additive by design. Nothing below it is required to consume the
- * runtime — the transcript, composer and tool timeline still render from Redux
- * exactly as before — so mounting it cannot regress a surface that ignores it.
- * What it provides is the runtime *context*: any component under it may use
- * assistant-ui's hooks and primitives, and the two views of the conversation
- * are guaranteed to agree because both read the same store.
+ * Settled process history (reasoning, tools and sub-agents) is read directly
+ * from the core transcript RPC, whose Rust projection cache is authoritative.
+ * Redux only supplies the existing message list and live socket deltas.
*
* ## Why the thread is a prop
*
diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx
index 87b97da7af..0565d8879a 100644
--- a/app/src/providers/ChatRuntimeProvider.tsx
+++ b/app/src/providers/ChatRuntimeProvider.tsx
@@ -39,7 +39,7 @@ import {
clearProcessingForThread,
clearStreamingAssistantForThread,
endInferenceTurn,
- fetchAndHydrateDerivedTranscript,
+ fetchAndHydrateCompletedTurnState,
markInferenceTurnStreaming,
parseToolFailure,
recordChatTurnUsage,
@@ -77,7 +77,7 @@ import {
setActiveThread,
setSelectedThread,
} from '../store/threadSlice';
-import { DERIVED_TRANSCRIPT_ENABLED, IS_PROD } from '../utils/config';
+import { IS_PROD } from '../utils/config';
import { AssistantUiRuntimeProvider } from './AssistantUiRuntimeProvider';
import { isProactiveConversationSurface, proactiveThreadPins } from './proactiveThreadPins';
@@ -473,14 +473,13 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
await flushQueuedFollowups(event.thread_id);
dispatch(endInferenceTurn({ threadId: event.thread_id }));
dispatch(clearThreadInferenceActive(event.thread_id));
- // Live-turn seam: the turn just settled and its line was appended to the
- // append-only transcript. Invalidate/refresh the thread's derived
- // settled-turn trails so the next reopen is fresh. The just-finished turn
- // is the newest, so the derived hydration skips it — this never fights the
- // live anchor, and it does not touch any socket delta handler.
- if (DERIVED_TRANSCRIPT_ENABLED) {
- void dispatch(fetchAndHydrateDerivedTranscript(event.thread_id));
- }
+ // Socket reducers keep only the current iteration's prose in the live
+ // buffer. Once the turn settles, replace that partial projection with
+ // the core's completed snapshot, whose ordered transcript contains every
+ // parent and sub-agent event from the whole turn. Doing this here (after
+ // ending the live lifecycle) matters: `hydrateRuntimeFromSnapshot`
+ // intentionally refuses to overwrite an actively streaming turn.
+ await dispatch(fetchAndHydrateCompletedTurnState(event.thread_id));
};
rtLog('subscribe_chat_events', { socket: socketStatus });
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index 53fa0323c9..d534546f6f 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -679,33 +679,55 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
[refresh]
);
+ // The core switches credentials before the refreshed app snapshot reaches
+ // React. Keep the token being installed visible to the expiry handler during
+ // that gap; otherwise a late 401 from the previous/cloud surface can clear a
+ // newly stored local session even though the core correctly ignores it.
+ const sessionTokenBeingStoredRef = useRef(null);
+
const storeSessionToken = useCallback(
async (token: string, user?: object) => {
logoutGuardUntilRef.current = 0;
- await storeSession(token, user ?? {});
+ sessionTokenBeingStoredRef.current = token;
try {
- await syncMemoryClientToken(token);
- memoryTokenRef.current = token;
- } catch (error) {
- console.warn('[core-state] memory client sync failed after session store:', error);
- }
- // refresh() drives refreshCore, which now owns identity-flip detection
- // and dispatches handleIdentityFlip when both prev and next are
- // authenticated and identities differ. The previous standalone
- // restartApp call here was redundant and skipped the persist purge,
- // letting redux-persist rehydrate the prior user's slices on launch
- // (#900). Restart now happens inside handleIdentityFlip after purge.
- // Swallow refresh failures here so a cold-boot `app_state_snapshot`
- // timeout post-login doesn't surface as an unhandled rejection
- // (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within
- // `POLL_MS`.
- await refresh().catch(err => {
- log('refresh failed after session store: %O', sanitizeError(err));
- });
- if (!isLocalSessionToken(token)) {
- await refreshTeams().catch(err => {
- log('refreshTeams failed after session store: %O', sanitizeError(err));
+ await storeSession(token, user ?? {});
+ try {
+ await syncMemoryClientToken(token);
+ memoryTokenRef.current = token;
+ } catch (error) {
+ console.warn('[core-state] memory client sync failed after session store:', error);
+ }
+ // refresh() drives refreshCore, which now owns identity-flip detection
+ // and dispatches handleIdentityFlip when both prev and next are
+ // authenticated and identities differ. The previous standalone
+ // restartApp call here was redundant and skipped the persist purge,
+ // letting redux-persist rehydrate the prior user's slices on launch
+ // (#900). Restart now happens inside handleIdentityFlip after purge.
+ // Swallow refresh failures here so a cold-boot `app_state_snapshot`
+ // timeout post-login doesn't surface as an unhandled rejection
+ // (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within
+ // `POLL_MS`.
+ // `refresh()` dedupes onto any poll already in flight. A poll that
+ // began before `storeSession` resolved answers with the pre-store
+ // snapshot; awaiting that one here would commit stale cloud identity,
+ // then the `finally` below would drop the local-token marker on it and
+ // a late confirmed 401 could clear the session just stored. Wait the
+ // stale poll out, then require a refresh that began after the store.
+ if (refreshInFlightRef.current) {
+ await refreshInFlightRef.current.catch(() => undefined);
+ }
+ await refresh().catch(err => {
+ log('refresh failed after session store: %O', sanitizeError(err));
});
+ if (!isLocalSessionToken(token)) {
+ await refreshTeams().catch(err => {
+ log('refreshTeams failed after session store: %O', sanitizeError(err));
+ });
+ }
+ } finally {
+ if (sessionTokenBeingStoredRef.current === token) {
+ sessionTokenBeingStoredRef.current = null;
+ }
}
},
[refresh, refreshTeams]
@@ -782,7 +804,9 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
// so re-registers are rare.
useEffect(() => {
const runReauth = async (method: string, source: string, reason: AuthExpiredReason) => {
- if (isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken)) {
+ const effectiveToken =
+ sessionTokenBeingStoredRef.current ?? getCoreStateSnapshot().snapshot.sessionToken;
+ if (isLocalSessionToken(effectiveToken)) {
log('auth-expired ignored for local session (method=%s source=%s)', method, source);
return;
}
diff --git a/app/src/providers/__tests__/AssistantUiRuntimeProvider.test.tsx b/app/src/providers/__tests__/AssistantUiRuntimeProvider.test.tsx
index f2f8e8e7dd..6ccc714038 100644
--- a/app/src/providers/__tests__/AssistantUiRuntimeProvider.test.tsx
+++ b/app/src/providers/__tests__/AssistantUiRuntimeProvider.test.tsx
@@ -9,16 +9,35 @@
*/
import { useAui, useAuiState } from '@assistant-ui/react';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
-import { act, render, screen } from '@testing-library/react';
+import { act, render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import chatRuntimeReducer, { streamDeltaReceived } from '../../store/chatRuntimeSlice';
+import { threadApi } from '../../services/api/threadApi';
+import chatRuntimeReducer, {
+ beginInferenceTurn,
+ markInferenceTurnStreaming,
+ streamDeltaReceived,
+} from '../../store/chatRuntimeSlice';
import threadReducer from '../../store/threadSlice';
import type { ThreadMessage } from '../../types/thread';
import { AssistantUiRuntimeProvider } from '../AssistantUiRuntimeProvider';
import { __resetChatSurfaces, registerChatSurface } from '../chatSurfaceHandlers';
+vi.mock('../../services/api/threadApi', () => ({
+ threadApi: {
+ getDerivedTranscript: vi
+ .fn()
+ .mockResolvedValue({
+ threadId: 't-aui',
+ items: [],
+ total: 0,
+ hasMore: false,
+ hasTranscript: false,
+ }),
+ },
+}));
+
const THREAD_ID = 't-aui';
function msg(id: string, sender: 'user' | 'agent', content: string): ThreadMessage {
@@ -63,6 +82,15 @@ function RuntimeProbe() {
.map(m => m.content.map(p => (p.type === 'text' ? p.text : '')).join(''))
.join('|')}
+
+ {thread.messages
+ .flatMap(message =>
+ message.role === 'assistant'
+ ? message.content.flatMap(part => (part.type === 'tool-call' ? [part.toolName] : []))
+ : []
+ )
+ .join('|')}
+
);
}
@@ -91,12 +119,14 @@ describe('AssistantUiRuntimeProvider', () => {
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
- it('surfaces the live stream as a running tail message', () => {
+ it('surfaces the live stream as a running tail message', async () => {
const store = buildStore([msg('a', 'user', 'question')]);
renderWith(store);
expect(screen.getByTestId('count')).toHaveTextContent('1');
act(() => {
+ store.dispatch(beginInferenceTurn({ threadId: THREAD_ID }));
+ store.dispatch(markInferenceTurnStreaming({ threadId: THREAD_ID }));
store.dispatch(
streamDeltaReceived({
threadId: THREAD_ID,
@@ -108,10 +138,39 @@ describe('AssistantUiRuntimeProvider', () => {
);
});
- expect(screen.getByTestId('count')).toHaveTextContent('2');
+ await waitFor(() => expect(screen.getByTestId('count')).toHaveTextContent('2'));
expect(screen.getByTestId('text')).toHaveTextContent('question|partial answer');
});
+ it('reads settled reasoning and tools directly from the core transcript RPC', async () => {
+ vi.mocked(threadApi.getDerivedTranscript).mockResolvedValueOnce({
+ threadId: THREAD_ID,
+ // RPC pages are newest-first: reverse traversal sees the boundary first.
+ items: [
+ {
+ kind: 'toolCall',
+ callId: 'call-web',
+ name: 'web_fetch',
+ args: { url: 'https://example.com' },
+ result: 'Example Domain',
+ status: 'success',
+ },
+ { kind: 'reasoning', text: 'I should fetch the source.' },
+ { kind: 'turnBoundary', requestId: 'req-core' },
+ ],
+ total: 3,
+ hasMore: false,
+ hasTranscript: true,
+ });
+ const answer = msg('answer', 'agent', 'done');
+ answer.extraMetadata = { requestId: 'req-core' };
+
+ renderWith(buildStore([msg('question', 'user', 'fetch it'), answer]));
+
+ await waitFor(() => expect(screen.getByTestId('tools')).toHaveTextContent('web_fetch'));
+ expect(threadApi.getDerivedTranscript).toHaveBeenCalledWith(THREAD_ID, { limit: 500 });
+ });
+
it('forwards onNew to the surface that owns the thread', async () => {
const send = vi.fn(async () => {});
registerChatSurface(THREAD_ID, { send });
diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx
index 6b3751857a..d7b4ebf31b 100644
--- a/app/src/providers/__tests__/CoreStateProvider.test.tsx
+++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx
@@ -536,6 +536,96 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled();
});
+ it('does not clear a local session while its refreshed snapshot is still pending', async () => {
+ const localToken = `eyJhbGciOiJub25lIn0.${window.btoa(JSON.stringify({ sub: 'local' }))}.local`;
+ const stored = deferred();
+ fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'cloud-user', sessionToken: 'old' }));
+ listTeams.mockResolvedValue([]);
+ vi.mocked(tauriCommands.storeSession).mockReset();
+ vi.mocked(tauriCommands.storeSession).mockReturnValue(stored.promise as never);
+ vi.mocked(tauriCommands.logout).mockReset();
+ vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never);
+
+ let ctx: CoreStateContextValue | undefined;
+ render(
+
+ (ctx = next)} />
+
+ );
+ await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
+
+ let storing!: Promise;
+ await act(async () => {
+ storing = ctx!.storeSessionToken(localToken, { id: 'local' });
+ await Promise.resolve();
+ window.dispatchEvent(
+ new CustomEvent('core-rpc-auth-expired', {
+ detail: {
+ method: 'openhuman.announcements_get_latest',
+ source: 'rpc',
+ reason: 'confirmed',
+ },
+ })
+ );
+ // Drain past the microtask queue: the handler may `await` before it can
+ // reach `logout`, and a single `Promise.resolve()` tick would let this
+ // assertion pass without that path having had a chance to run.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled();
+ stored.resolve();
+ await act(async () => storing);
+ });
+
+ it('keeps a stored local session when a pre-store poll settles after the store', async () => {
+ const localToken = `eyJhbGciOiJub25lIn0.${window.btoa(JSON.stringify({ sub: 'local' }))}.local`;
+ const prestorePoll = deferred();
+ // Bootstrap answers with the cloud identity, the poll started *before* the
+ // store is held open, and every poll after the store sees the local session.
+ fetchSnapshot
+ .mockResolvedValueOnce(makeSnapshot({ userId: 'cloud-user', sessionToken: 'old' }))
+ .mockReturnValueOnce(prestorePoll.promise as never)
+ .mockResolvedValue(makeSnapshot({ userId: 'local', sessionToken: localToken }));
+ listTeams.mockResolvedValue([]);
+ vi.mocked(tauriCommands.storeSession).mockReset();
+ vi.mocked(tauriCommands.storeSession).mockResolvedValue(undefined as never);
+ vi.mocked(tauriCommands.logout).mockReset();
+ vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never);
+
+ let ctx: CoreStateContextValue | undefined;
+ render(
+
+ (ctx = next)} />
+
+ );
+ await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
+
+ let storing!: Promise;
+ await act(async () => {
+ // A poll is already in flight when the local session is stored...
+ void ctx!.refresh();
+ storing = ctx!.storeSessionToken(localToken, { id: 'local' });
+ await new Promise(resolve => setTimeout(resolve, 0));
+ // ...and it settles with the stale cloud snapshot only after the store.
+ prestorePoll.resolve(makeSnapshot({ userId: 'cloud-user', sessionToken: 'old' }));
+ await storing;
+ });
+
+ expect(getCoreStateSnapshot().snapshot.sessionToken).toBe(localToken);
+
+ await act(async () => {
+ window.dispatchEvent(
+ new CustomEvent('core-rpc-auth-expired', {
+ detail: { method: 'openhuman.team_get_usage', source: 'rpc', reason: 'confirmed' },
+ })
+ );
+ await new Promise(resolve => setTimeout(resolve, 0));
+ });
+
+ expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled();
+ });
+
it('dispatching core-rpc-auth-expired triggers clearSession (and debounces repeated fires within 10s)', async () => {
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
diff --git a/app/src/providers/__tests__/assistantUiMessages.test.ts b/app/src/providers/__tests__/assistantUiMessages.test.ts
index 6a975f51d6..4a409046e9 100644
--- a/app/src/providers/__tests__/assistantUiMessages.test.ts
+++ b/app/src/providers/__tests__/assistantUiMessages.test.ts
@@ -133,6 +133,23 @@ describe('buildRuntimeMessages', () => {
expect(ids).toEqual(['a', STREAMING_TAIL_ID]);
});
+ it('does not keep a synthetic thinking/tool tail running after lifecycle completion', () => {
+ const projected = buildRuntimeMessages([msg({ id: 'answer', sender: 'agent' })], null, {
+ isRunning: false,
+ liveTimeline: [tool({ id: 'stale-tool', status: 'success' })],
+ liveTranscript: [{ kind: 'thinking', round: 1, seq: 0, text: 'already finished thinking' }],
+ });
+ const ids = projected.map(message => message.id);
+
+ expect(ids).toEqual(['answer']);
+ expect(ids).not.toContain(STREAMING_TAIL_ID);
+ expect(projected[0]?.content).toEqual([
+ { type: 'reasoning', text: 'already finished thinking' },
+ expect.objectContaining({ type: 'tool-call', toolCallId: 'stale-tool' }),
+ { type: 'text', text: 'hello' },
+ ]);
+ });
+
it('replays a settled turn reasoning and tool calls from its request id', () => {
const answer = msg({
id: 'answer',
@@ -143,7 +160,8 @@ describe('buildRuntimeMessages', () => {
const timeline = [tool({ id: 'call-1', status: 'success', result: 'found it' })];
const transcript = [
{ kind: 'thinking' as const, round: 1, seq: 0, text: 'need to search' },
- { kind: 'toolCall' as const, round: 1, seq: 1, callId: 'call-1' },
+ { kind: 'narration' as const, round: 1, seq: 1, text: 'I will check the sources.' },
+ { kind: 'toolCall' as const, round: 1, seq: 2, callId: 'call-1' },
];
expect(
@@ -153,6 +171,7 @@ describe('buildRuntimeMessages', () => {
})[0]?.content
).toEqual([
{ type: 'reasoning', text: 'need to search' },
+ { type: 'text', text: 'I will check the sources.' },
expect.objectContaining({
type: 'tool-call',
toolCallId: 'call-1',
@@ -163,6 +182,181 @@ describe('buildRuntimeMessages', () => {
]);
});
+ it('chronologically anchors persisted trails to async agent messages without request ids', () => {
+ const acknowledgement = msg({
+ id: 'ack',
+ sender: 'agent',
+ content: 'Accepted background work',
+ extraMetadata: {},
+ });
+ const content = buildRuntimeMessages([acknowledgement], null, {
+ isRunning: false,
+ turnTimelines: { 'request-async': [tool({ id: 'async-tool', status: 'success' })] },
+ turnTranscripts: {
+ 'request-async': [
+ { kind: 'thinking', round: 1, seq: 0, text: 'delegate this research' },
+ { kind: 'toolCall', round: 1, seq: 1, callId: 'async-tool' },
+ ],
+ },
+ })[0]?.content;
+
+ expect(content).toEqual([
+ { type: 'reasoning', text: 'delegate this research' },
+ expect.objectContaining({ type: 'tool-call', toolCallId: 'async-tool' }),
+ { type: 'text', text: 'Accepted background work' },
+ ]);
+ });
+
+ it('renders final streamed narration only once', () => {
+ const finalText = 'hey! what is up?';
+ const answer = msg({ id: 'answer', sender: 'agent', content: finalText });
+ const content = buildRuntimeMessages([answer], null, {
+ turnTranscripts: { request: [{ kind: 'narration', round: 1, seq: 0, text: finalText }] },
+ turnTimelines: { request: [] },
+ })[0]?.content;
+
+ expect(content).toEqual([{ type: 'text', text: finalText }]);
+ });
+
+ it('coalesces legacy assistant segments into one bubble with one tool trail', () => {
+ const requestId = 'legacy-segmented-request';
+ const intro = "Here's the crypto picture today:";
+ const finalText = `${intro}\n\nBitcoin is trading around $77,000.`;
+ const messages = [
+ msg({ id: 'user', content: 'What is happening with Bitcoin?' }),
+ msg({
+ id: 'tool-envelope',
+ sender: 'agent',
+ content: JSON.stringify({
+ content: null,
+ tool_calls: [
+ { id: 'call-search', name: 'web_search_tool', arguments: '{"query":"bitcoin"}' },
+ ],
+ }),
+ extraMetadata: { requestId },
+ }),
+ msg({ id: 'intro', sender: 'agent', content: intro, extraMetadata: { requestId } }),
+ msg({ id: 'final', sender: 'agent', content: finalText, extraMetadata: { requestId } }),
+ ];
+
+ const projected = buildRuntimeMessages(messages, null, {
+ turnTimelines: {
+ [requestId]: [
+ tool({ id: 'call-search', name: 'tool', status: 'success', result: 'market results' }),
+ ],
+ },
+ turnTranscripts: {
+ [requestId]: [{ kind: 'toolCall', round: 1, seq: 0, callId: 'call-search' }],
+ },
+ });
+
+ expect(projected).toHaveLength(2);
+ expect(projected[1]).toMatchObject({ id: 'final', role: 'assistant' });
+ expect(projected[1]?.content).toEqual([
+ expect.objectContaining({
+ type: 'tool-call',
+ toolCallId: 'call-search',
+ toolName: 'web_search_tool',
+ }),
+ { type: 'text', text: finalText },
+ ]);
+ });
+
+ it('does not coalesce adjacent assistant turns with different request ids', () => {
+ const projected = buildRuntimeMessages(
+ [
+ msg({ id: 'first', sender: 'agent', content: 'first', extraMetadata: { requestId: 'r1' } }),
+ msg({
+ id: 'second',
+ sender: 'agent',
+ content: 'second',
+ extraMetadata: { requestId: 'r2' },
+ }),
+ ],
+ null
+ );
+
+ expect(projected.map(message => message.id)).toEqual(['first', 'second']);
+ });
+
+ it('keeps a scoped standalone delivery out of the adjacent legacy runs', () => {
+ // Legacy segments carry no request id; a background delivery persisted by
+ // the core carries no request id either but is stamped with a `scope`.
+ // Without the marker the three rows read as one segmented answer and the
+ // delivery's text and metadata would be folded into its neighbours.
+ const projected = buildRuntimeMessages(
+ [
+ msg({ id: 'seg-a', sender: 'agent', content: 'first paragraph' }),
+ msg({
+ id: 'delivery',
+ sender: 'agent',
+ content: 'Background result: inbox digest',
+ extraMetadata: { scope: 'autonomous_task_result', success: true },
+ }),
+ msg({ id: 'seg-b', sender: 'agent', content: 'a later paragraph' }),
+ ],
+ null
+ );
+
+ expect(projected.map(message => message.id)).toEqual(['seg-a', 'delivery', 'seg-b']);
+ expect(projected[1]?.content).toEqual([
+ { type: 'text', text: 'Background result: inbox digest' },
+ ]);
+ });
+
+ it('does not let a scoped delivery absorb the identified segment before it', () => {
+ const projected = buildRuntimeMessages(
+ [
+ msg({
+ id: 'answer',
+ sender: 'agent',
+ content: 'answer',
+ extraMetadata: { requestId: 'r1' },
+ }),
+ msg({
+ id: 'delivery',
+ sender: 'agent',
+ content: 'worker output',
+ extraMetadata: { scope: 'worker_thread', requestId: 'r-worker' },
+ }),
+ ],
+ null
+ );
+
+ expect(projected.map(message => message.id)).toEqual(['answer', 'delivery']);
+ });
+
+ it('does not hand a later turn trail to an earlier trail-less answer', () => {
+ // Two unanchored answers, one unclaimed trail. Positional pairing would
+ // give the trail to `earlier` (which produced nothing) and leave `later`
+ // (which actually used the tool) bare — a wrong attribution, not a loss.
+ const projected = buildRuntimeMessages(
+ [
+ msg({ id: 'ask-1', content: 'first question' }),
+ msg({ id: 'earlier', sender: 'agent', content: 'plain answer', extraMetadata: {} }),
+ msg({ id: 'ask-2', content: 'second question' }),
+ msg({ id: 'later', sender: 'agent', content: 'tool answer', extraMetadata: {} }),
+ ],
+ null,
+ {
+ isRunning: false,
+ turnTimelines: { 'request-later': [tool({ id: 'later-tool', status: 'success' })] },
+ turnTranscripts: {
+ 'request-later': [{ kind: 'toolCall', round: 1, seq: 0, callId: 'later-tool' }],
+ },
+ }
+ );
+
+ const toolBearing = projected
+ .filter(
+ message =>
+ Array.isArray(message.content) && message.content.some(part => part.type === 'tool-call')
+ )
+ .map(message => message.id);
+ expect(toolBearing).not.toContain('earlier');
+ expect(projected[1]?.content).toEqual([{ type: 'text', text: 'plain answer' }]);
+ });
+
/**
* The crash this guards: assistant-ui keys tool parts as `toolCallId-${id}`
* and throws "Duplicate key … in useResources" on a repeat, taking the whole
@@ -237,3 +431,71 @@ describe('buildRuntimeMessages', () => {
parse.mockRestore();
});
});
+
+describe('recovered tool names', () => {
+ it('does not consume a recovered name on an entry it does not rename', () => {
+ // `recoveredNames` comes from tool-call envelopes, so it only ever holds
+ // names for the *generic* rows. Advancing the cursor on every entry made a
+ // named row eat the first recovered name: the first generic row then took
+ // the second name and the last one kept the placeholder.
+ const converted = toThreadMessageLike(
+ msg({
+ id: 'a',
+ sender: 'agent',
+ content: 'done',
+ extraMetadata: { assistantUiToolNames: ['web_search', 'web_fetch'] },
+ }),
+ [
+ tool({ id: 'c1', name: 'read_file', seq: 0, status: 'success' }),
+ tool({ id: 'c2', name: 'tool', seq: 1, status: 'success' }),
+ tool({ id: 'c3', name: 'tool', seq: 2, status: 'success' }),
+ ]
+ );
+ const names = (converted.content as unknown as { type: string; toolName?: string }[])
+ .filter(part => part.type === 'tool-call')
+ .map(part => part.toolName);
+ expect(names).toEqual(['read_file', 'web_search', 'web_fetch']);
+ });
+});
+
+describe('terminal tool status', () => {
+ it('carries a failed tool status through to the rendered part', () => {
+ // assistant-ui's tool-call part has no status field, so a failed tool that
+ // produced output used to arrive as a bare result and read as success.
+ const converted = toThreadMessageLike(msg({ id: 'a', sender: 'agent', content: 'done' }), [
+ tool({ id: 'c1', name: 'web_search', seq: 0, status: 'error', result: 'boom' }),
+ ]);
+ const part = (converted.content as unknown as { type: string; result?: unknown }[]).find(
+ candidate => candidate.type === 'tool-call'
+ );
+ expect(part?.result).toMatchObject({ status: 'error', value: 'boom' });
+ });
+
+ it('leaves a successful tool result untouched', () => {
+ const converted = toThreadMessageLike(msg({ id: 'a', sender: 'agent', content: 'done' }), [
+ tool({ id: 'c1', name: 'web_search', seq: 0, status: 'success', result: 'the answer' }),
+ ]);
+ const part = (converted.content as unknown as { type: string; result?: unknown }[]).find(
+ candidate => candidate.type === 'tool-call'
+ );
+ expect(part?.result).toBe('the answer');
+ });
+});
+
+describe('narration merged into the final answer', () => {
+ it('does not render narration that the final text already contains', () => {
+ // `mergedAssistantText` prefers the longest text when it *contains* every
+ // segment, so the duplicate is a substring rather than an exact match and
+ // the old equality guard let it through twice.
+ const finalText = 'I will check the sources. Here is what I found.';
+ const converted = toThreadMessageLike(
+ msg({ id: 'a', sender: 'agent', content: finalText }),
+ [],
+ [{ kind: 'narration', round: 1, seq: 0, text: 'I will check the sources.' }]
+ );
+ const texts = (converted.content as unknown as { type: string; text?: string }[])
+ .filter(part => part.type === 'text')
+ .map(part => part.text);
+ expect(texts).toEqual([finalText]);
+ });
+});
diff --git a/app/src/providers/__tests__/useCoreTranscriptProjection.test.tsx b/app/src/providers/__tests__/useCoreTranscriptProjection.test.tsx
new file mode 100644
index 0000000000..412e3da815
--- /dev/null
+++ b/app/src/providers/__tests__/useCoreTranscriptProjection.test.tsx
@@ -0,0 +1,79 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { threadApi } from '../../services/api/threadApi';
+import type { DerivedDisplayItem, DerivedTranscriptPage } from '../../types/derivedTranscript';
+import { useCoreTranscriptProjection } from '../useOpenHumanExternalStore';
+
+vi.mock('../../services/api/threadApi', () => ({ threadApi: { getDerivedTranscript: vi.fn() } }));
+
+const THREAD = 'thread-long';
+
+/** Newest-first items for one settled turn: a tool call after its boundary. */
+function turn(requestId: string, callId: string): DerivedDisplayItem[] {
+ return [
+ { kind: 'toolCall', callId, name: 'web_search_tool', status: 'success', result: 'ok' },
+ { kind: 'assistantMessage', content: `answer ${requestId}`, requestId, iteration: 1 },
+ { kind: 'turnBoundary', requestId },
+ ];
+}
+
+function page(over: Partial): DerivedTranscriptPage {
+ return { threadId: THREAD, items: [], total: 0, hasMore: false, hasTranscript: true, ...over };
+}
+
+describe('useCoreTranscriptProjection paging', () => {
+ beforeEach(() => {
+ vi.mocked(threadApi.getDerivedTranscript).mockReset();
+ });
+
+ it('walks every older page and projects the whole history', async () => {
+ vi.mocked(threadApi.getDerivedTranscript)
+ .mockResolvedValueOnce(
+ page({ items: turn('r-new', 'call-new'), total: 6, hasMore: true, nextCursor: 'c1' })
+ )
+ .mockResolvedValueOnce(page({ items: turn('r-old', 'call-old'), total: 6, hasMore: false }));
+
+ const { result } = renderHook(() => useCoreTranscriptProjection(THREAD, 'rev-1', undefined));
+
+ await waitFor(() => expect(Object.keys(result.current.timelines)).toHaveLength(2));
+ expect(threadApi.getDerivedTranscript).toHaveBeenCalledTimes(2);
+ expect(threadApi.getDerivedTranscript).toHaveBeenLastCalledWith(THREAD, {
+ limit: 500,
+ cursor: 'c1',
+ });
+ expect(result.current.timelines['r-old']?.[0]).toMatchObject({ id: 'call-old' });
+ expect(result.current.timelines['r-new']?.[0]).toMatchObject({ id: 'call-new' });
+ });
+
+ it('stops when the core reports no more pages', async () => {
+ vi.mocked(threadApi.getDerivedTranscript).mockResolvedValueOnce(
+ page({ items: turn('r-only', 'call-only'), total: 3, hasMore: false })
+ );
+
+ const { result } = renderHook(() => useCoreTranscriptProjection(THREAD, 'rev-1', undefined));
+
+ await waitFor(() => expect(Object.keys(result.current.timelines)).toEqual(['r-only']));
+ expect(threadApi.getDerivedTranscript).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a page that lands after the thread changed', async () => {
+ const older = new Promise(() => {
+ /* never resolves: the walk is still in flight when the thread switches */
+ });
+ vi.mocked(threadApi.getDerivedTranscript)
+ .mockResolvedValueOnce(
+ page({ items: turn('r-new', 'call-new'), total: 6, hasMore: true, nextCursor: 'c1' })
+ )
+ .mockReturnValueOnce(older);
+
+ const { result, rerender } = renderHook(
+ ({ thread }) => useCoreTranscriptProjection(thread, 'rev-1', undefined),
+ { initialProps: { thread: THREAD as string | null } }
+ );
+ await waitFor(() => expect(Object.keys(result.current.timelines)).toEqual(['r-new']));
+
+ rerender({ thread: null });
+ expect(result.current.timelines).toEqual({});
+ });
+});
diff --git a/app/src/providers/assistantUiMessages.ts b/app/src/providers/assistantUiMessages.ts
index b2a837174e..a261c8a415 100644
--- a/app/src/providers/assistantUiMessages.ts
+++ b/app/src/providers/assistantUiMessages.ts
@@ -45,6 +45,8 @@ const conversionCache = new WeakMap();
const EMPTY_TIMELINE: readonly ToolTimelineEntry[] = [];
const EMPTY_TRANSCRIPT: readonly ProcessingTranscriptItem[] = [];
+const RECOVERED_TOOL_NAMES_KEY = 'assistantUiToolNames';
+
/** Synthetic id for the live streaming tail. Stable so React reconciles it. */
export const STREAMING_TAIL_ID = '__openhuman_streaming_tail__';
@@ -76,6 +78,30 @@ function toolArgs(entry: ToolTimelineEntry): Record {
}
}
+/**
+ * The `result` payload for a settled non-sub-agent tool part.
+ *
+ * assistant-ui's tool-call part has no status field, so a terminal status has
+ * to travel inside `result` or not at all. It did not travel: the adapter fell
+ * back to `result !== undefined`, which reads as success, and a failed or
+ * cancelled tool rendered with a "done" label and a check.
+ *
+ * A tool that produced no output already reported `{ status, failure }` here,
+ * so only the failed-*with*-output case needed a shape — `value` carries the
+ * real output beside the status, and {@link isToolStatusEnvelope} unwraps it.
+ * The success path is byte-identical to before, deliberately: every reader of
+ * a successful result keeps seeing exactly what it saw.
+ */
+function toolResultPayload(entry: ToolTimelineEntry): unknown {
+ const terminalFailure = entry.status === 'error' || entry.status === 'cancelled';
+ if (!terminalFailure) return entry.result ?? { status: entry.status, failure: entry.failure };
+ return {
+ status: entry.status,
+ failure: entry.failure,
+ ...(entry.result !== undefined ? { value: entry.result } : {}),
+ };
+}
+
function toolPart(entry: ToolTimelineEntry): ThreadAssistantMessagePart {
const running = entry.status === 'running' || entry.status === 'awaiting_user';
const isSubagent = entry.name.startsWith('subagent:') || entry.subagent !== undefined;
@@ -97,7 +123,7 @@ function toolPart(entry: ToolTimelineEntry): ThreadAssistantMessagePart {
? {
result: isSubagent
? (entry.subagent ?? { status: entry.status })
- : (entry.result ?? { status: entry.status, failure: entry.failure }),
+ : toolResultPayload(entry),
}
: {}),
};
@@ -140,9 +166,20 @@ function assistantParts(
parts.push(toolPart(entry));
}
}
- // Narration is process commentary, not the final assistant answer. The
- // legacy pane keeps it in the processing view; projecting it as ordinary
- // text here would duplicate prose around the persisted final response.
+ if (
+ item.kind === 'narration' &&
+ item.text.trim().length > 0 &&
+ !text.trim().includes(item.text.trim())
+ ) {
+ // Narration emitted before a tool call is assistant content in its own
+ // right. Keep it inline in assistant-ui's ordered part stream; the final
+ // answer is appended separately below. A final-round narration is the
+ // same streamed bytes as that answer and must not render twice —
+ // `includes`, not equality, because `mergedAssistantText` prefers the
+ // longest text when it *contains* every segment, so the duplicate is a
+ // substring rather than an exact match.
+ parts.push({ type: 'text', text: item.text });
+ }
}
for (const entry of [...timeline].sort((a, b) => a.seq - b.seq)) {
@@ -160,6 +197,124 @@ function stringArray(value: unknown): string[] {
: [];
}
+function requestIdOf(message: ThreadMessage): string | undefined {
+ const requestId = message.extraMetadata?.requestId;
+ return typeof requestId === 'string' && requestId.length > 0 ? requestId : undefined;
+}
+
+function isGenericToolName(name: string): boolean {
+ return ['', 'tool', 'unknown', 'unknown_tool'].includes(name.trim().toLowerCase());
+}
+
+function recoverTimelineToolNames(
+ timeline: readonly ToolTimelineEntry[],
+ recoveredNames: readonly string[]
+): readonly ToolTimelineEntry[] {
+ if (recoveredNames.length === 0 || !timeline.some(entry => isGenericToolName(entry.name))) {
+ return timeline;
+ }
+ // Advance only when a name is actually consumed. `recoveredNames` comes from
+ // tool-call envelopes, so it is not positionally aligned with the whole
+ // timeline: incrementing on every entry made `[read_file, tool, tool]` +
+ // `[web_search, web_fetch]` mis-assign `web_fetch` to the first generic row
+ // and leave the second one named `tool`.
+ let recoveredIndex = 0;
+ return timeline.map(entry => {
+ if (!isGenericToolName(entry.name)) return entry;
+ const recovered = recoveredNames[recoveredIndex];
+ if (!recovered) return entry;
+ recoveredIndex += 1;
+ return { ...entry, name: recovered };
+ });
+}
+
+function mergedAssistantText(messages: readonly ThreadMessage[]): string {
+ const texts = messages
+ .map(message => unwrapToolCallEnvelope(message.content ?? '').text)
+ .filter(text => text.trim().length > 0)
+ .filter((text, index, all) => all.indexOf(text) === index);
+ if (texts.length < 2) return texts[0] ?? '';
+
+ // Legacy web delivery persisted both paragraph-sized segments and the full
+ // final response. Prefer the complete response when it contains every
+ // segment; otherwise retain each distinct assistant emission in order.
+ const longest = [...texts].sort((left, right) => right.length - left.length)[0] ?? '';
+ if (texts.every(text => longest.includes(text.trim()))) return longest;
+ return texts.join('\n\n');
+}
+
+function mergeAssistantRun(messages: readonly ThreadMessage[]): ThreadMessage {
+ if (messages.length === 1) return messages[0];
+ const first = messages[0];
+ const last = messages[messages.length - 1];
+ const extraMetadata = Object.assign({}, ...messages.map(message => message.extraMetadata));
+ const requestId = messages.map(requestIdOf).find(Boolean);
+ const toolNames = messages.flatMap(
+ message => unwrapToolCallEnvelope(message.content ?? '').toolNames
+ );
+ if (requestId) extraMetadata.requestId = requestId;
+ if (toolNames.length > 0) extraMetadata[RECOVERED_TOOL_NAMES_KEY] = toolNames;
+ return {
+ ...last,
+ content: mergedAssistantText(messages),
+ createdAt: first.createdAt,
+ extraMetadata,
+ };
+}
+
+/**
+ * A row the core persisted as a complete, self-contained delivery — an
+ * autonomous task result, a worker-thread hand-off, a workflow proposal. Core
+ * writers stamp `extraMetadata.scope` on every such row; the legacy segmented
+ * path never did. That positive marker is what tells an async delivery apart
+ * from a paragraph of the answer next to it, since neither carries a request
+ * id on the wire.
+ */
+function isStandaloneDelivery(message: ThreadMessage): boolean {
+ return typeof message.extraMetadata?.scope === 'string';
+}
+
+/**
+ * Collapse legacy paragraph/tool-envelope rows into one assistant turn.
+ *
+ * The old interactive-web delivery path persisted each segment as a separate
+ * agent message. Consecutive assistant rows cannot cross a user turn; when
+ * both rows carry request ids, a differing id is the explicit boundary, and a
+ * scoped standalone delivery ({@link isStandaloneDelivery}) is always its own
+ * turn — it neither joins the run before it nor seeds the run after it.
+ */
+function coalesceAssistantSegments(messages: readonly ThreadMessage[]): ThreadMessage[] {
+ const out: ThreadMessage[] = [];
+ let run: ThreadMessage[] = [];
+ let runRequestId: string | undefined;
+
+ const flush = () => {
+ if (run.length > 0) out.push(mergeAssistantRun(run));
+ run = [];
+ runRequestId = undefined;
+ };
+
+ for (const message of messages) {
+ if (message.sender !== 'agent' || message.extraMetadata?.hidden) {
+ flush();
+ out.push(message);
+ continue;
+ }
+ if (isStandaloneDelivery(message)) {
+ flush();
+ run.push(message);
+ flush();
+ continue;
+ }
+ const requestId = requestIdOf(message);
+ if (run.length > 0 && runRequestId && requestId && runRequestId !== requestId) flush();
+ run.push(message);
+ runRequestId ??= requestId;
+ }
+ flush();
+ return out;
+}
+
function mimeTypeFromDataUri(dataUri: string): string {
return dataUri.match(/^data:([^;,]+)/i)?.[1] ?? 'application/octet-stream';
}
@@ -210,13 +365,19 @@ export function toThreadMessageLike(
const cached = conversionCache.get(msg);
if (cached?.timeline === timeline && cached.transcript === transcript) return cached.converted;
- const text =
- msg.sender === 'agent' ? unwrapToolCallEnvelope(msg.content ?? '').text : (msg.content ?? '');
+ const unwrapped = unwrapToolCallEnvelope(msg.content ?? '');
+ const text = msg.sender === 'agent' ? unwrapped.text : (msg.content ?? '');
+ const recoveredToolNames = [
+ ...unwrapped.toolNames,
+ ...stringArray(msg.extraMetadata?.[RECOVERED_TOOL_NAMES_KEY]),
+ ];
+ const effectiveTimeline = recoverTimelineToolNames(timeline, recoveredToolNames);
const converted: ThreadMessageLike = {
id: msg.id,
role: msg.sender === 'agent' ? 'assistant' : 'user',
- content: msg.sender === 'agent' ? assistantParts(text, timeline, transcript) : userParts(msg),
+ content:
+ msg.sender === 'agent' ? assistantParts(text, effectiveTimeline, transcript) : userParts(msg),
createdAt: new Date(msg.createdAt),
...(msg.sender === 'agent' && msg.extraMetadata?.stopped === true
? { status: { type: 'incomplete' as const, reason: 'cancelled' as const } }
@@ -260,6 +421,8 @@ export function streamingTailMessage(
}
export type AssistantUiProjection = {
+ /** Whether the synthetic live tail has an active core turn driving it. */
+ isRunning?: boolean;
liveTimeline?: readonly ToolTimelineEntry[];
liveTranscript?: readonly ProcessingTranscriptItem[];
turnTimelines?: Readonly>;
@@ -279,26 +442,86 @@ export function buildRuntimeMessages(
streaming: StreamingAssistantState | null,
projection: AssistantUiProjection = {}
): ThreadMessageLike[] {
+ const coalescedMessages = coalesceAssistantSegments(messages);
const out: ThreadMessageLike[] = [];
- for (const msg of messages) {
+ const claimedRequestIds = new Set(
+ coalescedMessages.flatMap(message =>
+ message.sender === 'agent' && typeof message.extraMetadata?.requestId === 'string'
+ ? [message.extraMetadata.requestId]
+ : []
+ )
+ );
+ const projectedRequestIds = [
+ ...new Set([
+ ...Object.keys(projection.turnTimelines ?? {}),
+ ...Object.keys(projection.turnTranscripts ?? {}),
+ ]),
+ ].filter(requestId => !claimedRequestIds.has(requestId));
+ // Async acknowledgements/background deliveries can be persisted without
+ // message-level request metadata. The transcript maps are chronological and
+ // request-keyed, so unclaimed trails can be paired with unanchored agent
+ // messages in order — but only when the two sets are the same size. With a
+ // surplus of unanchored messages, positional pairing hands a later turn's
+ // tools to an earlier trail-less answer and leaves the real answer bare;
+ // rendering those trails nowhere is the lesser wrong.
+ const unanchoredAgentCount = coalescedMessages.filter(
+ message =>
+ message.sender === 'agent' &&
+ !message.extraMetadata?.hidden &&
+ typeof message.extraMetadata?.requestId !== 'string'
+ ).length;
+ const pairOrphanTrails =
+ projectedRequestIds.length > 0 && projectedRequestIds.length === unanchoredAgentCount;
+ let orphanRequestCursor = 0;
+ const lastVisibleAgentId = [...coalescedMessages]
+ .reverse()
+ .find(message => message.sender === 'agent' && !message.extraMetadata?.hidden)?.id;
+ for (const msg of coalescedMessages) {
if (msg.extraMetadata?.hidden) continue;
const requestId =
msg.sender === 'agent' && typeof msg.extraMetadata?.requestId === 'string'
? msg.extraMetadata.requestId
: undefined;
+ const effectiveRequestId =
+ requestId ??
+ (msg.sender === 'agent' && pairOrphanTrails
+ ? projectedRequestIds[orphanRequestCursor++]
+ : undefined);
+ const persistedTimeline = effectiveRequestId
+ ? projection.turnTimelines?.[effectiveRequestId]
+ : undefined;
+ const persistedTranscript = effectiveRequestId
+ ? projection.turnTranscripts?.[effectiveRequestId]
+ : undefined;
+ // `chat_done` clears the active lifecycle before the completed snapshot is
+ // indexed into the request maps. Keep the just-settled tools/reasoning on
+ // the final assistant message during that handoff; never mint a running
+ // synthetic tail for them.
+ const useSettledLiveFallback =
+ projection.isRunning === false &&
+ msg.id === lastVisibleAgentId &&
+ !persistedTimeline &&
+ !persistedTranscript;
out.push(
toThreadMessageLike(
msg,
- requestId ? (projection.turnTimelines?.[requestId] ?? EMPTY_TIMELINE) : EMPTY_TIMELINE,
- requestId ? (projection.turnTranscripts?.[requestId] ?? EMPTY_TRANSCRIPT) : EMPTY_TRANSCRIPT
+ persistedTimeline ??
+ (useSettledLiveFallback ? (projection.liveTimeline ?? EMPTY_TIMELINE) : EMPTY_TIMELINE),
+ persistedTranscript ??
+ (useSettledLiveFallback
+ ? (projection.liveTranscript ?? EMPTY_TRANSCRIPT)
+ : EMPTY_TRANSCRIPT)
)
);
}
- const tail = streamingTailMessage(
- streaming,
- projection.liveTimeline ?? EMPTY_TIMELINE,
- projection.liveTranscript ?? EMPTY_TRANSCRIPT
- );
+ const tail =
+ projection.isRunning === false
+ ? null
+ : streamingTailMessage(
+ streaming,
+ projection.liveTimeline ?? EMPTY_TIMELINE,
+ projection.liveTranscript ?? EMPTY_TRANSCRIPT
+ );
if (tail) out.push(tail);
return out;
}
diff --git a/app/src/providers/useOpenHumanExternalStore.ts b/app/src/providers/useOpenHumanExternalStore.ts
index 60390f2fe1..47b88c1840 100644
--- a/app/src/providers/useOpenHumanExternalStore.ts
+++ b/app/src/providers/useOpenHumanExternalStore.ts
@@ -1,7 +1,10 @@
import type { AppendMessage } from '@assistant-ui/react';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { mapDisplayItems } from '../features/conversations/derived/mapDisplayItems';
+import { threadApi } from '../services/api/threadApi';
import { useAppSelector } from '../store/hooks';
+import type { DerivedDisplayItem } from '../types/derivedTranscript';
import type { ThreadMessage } from '../types/thread';
import { buildRuntimeMessages } from './assistantUiMessages';
import { getChatSurface } from './chatSurfaceHandlers';
@@ -10,6 +13,104 @@ const EMPTY_MESSAGES: ThreadMessage[] = [];
const EMPTY_TIMELINE: never[] = [];
const EMPTY_TRANSCRIPT: never[] = [];
const EMPTY_TURN_MAP = {};
+/** Items per derived-transcript RPC page; the core caps a page at this size. */
+const DERIVED_TRANSCRIPT_PAGE_LIMIT = 500;
+/**
+ * Upper bound on pages walked for one thread (10k items). A thread longer than
+ * this is truncated at its oldest end rather than fetched without limit; the
+ * turn-bounded RPC contract that removes the ceiling altogether is tracked with
+ * the transcript RPC, not here.
+ */
+const DERIVED_TRANSCRIPT_MAX_PAGES = 20;
+
+type CoreTranscriptProjection = {
+ threadId: string | null;
+ timelines: ReturnType['timelines'];
+ transcripts: ReturnType['transcripts'];
+};
+
+const EMPTY_CORE_TRANSCRIPT: CoreTranscriptProjection = {
+ threadId: null,
+ timelines: EMPTY_TURN_MAP,
+ transcripts: EMPTY_TURN_MAP,
+};
+
+/**
+ * Read settled process history straight from the core's transcript projection.
+ * The Rust side owns a bounded, mtime-keyed LRU, so this hook deliberately does
+ * not establish a second Redux transcript store or duplicate cache policy.
+ */
+export function useCoreTranscriptProjection(
+ threadId: string | null,
+ revision: string,
+ liveRequestId: string | undefined
+): CoreTranscriptProjection {
+ const [projection, setProjection] = useState(EMPTY_CORE_TRANSCRIPT);
+
+ useEffect(() => {
+ if (!threadId) {
+ setProjection(EMPTY_CORE_TRANSCRIPT);
+ return;
+ }
+ // Defensive for narrow test/embedder shims that expose only a subset of
+ // threadApi. Production builds always provide this method.
+ if (typeof threadApi.getDerivedTranscript !== 'function') {
+ setProjection({ threadId, timelines: EMPTY_TURN_MAP, transcripts: EMPTY_TURN_MAP });
+ return;
+ }
+ let cancelled = false;
+ const skipRequestIds = liveRequestId ? new Set([liveRequestId]) : undefined;
+ const project = (items: DerivedDisplayItem[]) => {
+ const mapped = mapDisplayItems(items, { skipRequestIds });
+ setProjection({ threadId, timelines: mapped.timelines, transcripts: mapped.transcripts });
+ };
+ void (async () => {
+ try {
+ const first = await threadApi.getDerivedTranscript(threadId, {
+ limit: DERIVED_TRANSCRIPT_PAGE_LIMIT,
+ });
+ if (cancelled) return;
+ if (!first.hasTranscript) {
+ setProjection({ threadId, timelines: EMPTY_TURN_MAP, transcripts: EMPTY_TURN_MAP });
+ return;
+ }
+ // Paint the newest page immediately, then walk the older pages and
+ // re-project once with the whole history. A single page silently
+ // dropped everything older than 500 items on a long thread, and a
+ // page that begins mid-turn hides that turn's leading tool calls until
+ // its boundary is in view — both only resolve with the full list.
+ let items = first.items;
+ project(items);
+ let cursor = first.hasMore ? first.nextCursor : undefined;
+ let pages = 1;
+ while (cursor && pages < DERIVED_TRANSCRIPT_MAX_PAGES) {
+ const page = await threadApi.getDerivedTranscript(threadId, {
+ limit: DERIVED_TRANSCRIPT_PAGE_LIMIT,
+ cursor,
+ });
+ if (cancelled) return;
+ // Pages are newest-first and each next page is older, so appending
+ // keeps the newest-first order `mapDisplayItems` expects.
+ items = [...items, ...page.items];
+ pages += 1;
+ cursor = page.hasMore ? page.nextCursor : undefined;
+ }
+ if (pages > 1) project(items);
+ } catch {
+ // A missing/older core has no settled process trail; message text and
+ // the live socket projection remain usable. Navigation must not fail.
+ if (!cancelled) {
+ setProjection({ threadId, timelines: EMPTY_TURN_MAP, transcripts: EMPTY_TURN_MAP });
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [liveRequestId, revision, threadId]);
+
+ return projection.threadId === threadId ? projection : EMPTY_CORE_TRANSCRIPT;
+}
/** Flatten an assistant-ui append payload down to the plain text our core takes. */
function appendMessageText(message: AppendMessage): string {
@@ -22,10 +123,9 @@ function appendMessageText(message: AppendMessage): string {
/**
* Build the `ExternalStoreAdapter` that backs `useExternalStoreRuntime`.
*
- * Read side: a pure projection of Redux. Write side: forwarded to the surface
- * that owns the thread (see `chatSurfaceHandlers`). The runtime therefore gets
- * a faithful, live view of the conversation and a working action API without
- * holding any state of its own.
+ * Settled messages and live deltas remain in their existing UI stores, while
+ * reasoning/tool/sub-agent history comes directly from the core transcript
+ * projection. Redux is not a second transcript database.
*/
export function useOpenHumanExternalStore(threadId: string | null) {
const messages = useAppSelector(state =>
@@ -48,36 +148,33 @@ export function useOpenHumanExternalStore(threadId: string | null) {
? (state.chatRuntime.processingByThread?.[threadId] ?? EMPTY_TRANSCRIPT)
: EMPTY_TRANSCRIPT
);
- const turnTimelines = useAppSelector(state =>
- threadId
- ? (state.chatRuntime.turnTimelinesByThread?.[threadId] ?? EMPTY_TURN_MAP)
- : EMPTY_TURN_MAP
- );
- const turnTranscripts = useAppSelector(state =>
- threadId
- ? (state.chatRuntime.turnTranscriptsByThread?.[threadId] ?? EMPTY_TURN_MAP)
- : EMPTY_TURN_MAP
+ const settledRevision = `${messages.at(-1)?.id ?? ''}:${messages.at(-1)?.content?.length ?? 0}:${lifecycle ?? ''}`;
+ const coreTranscript = useCoreTranscriptProjection(
+ threadId,
+ settledRevision,
+ streaming?.requestId
);
+ // `started` and `streaming` are both in-flight. A completed turn can retain
+ // its tool/reasoning arrays while the persisted projection catches up; those
+ // arrays must not mint a forever-running assistant-ui tail.
+ const isRunning = lifecycle === 'started' || lifecycle === 'streaming';
+
// Recomputed only when the settled transcript or the live tail changes.
// Settled messages are converted through an identity-keyed cache, so a token
// landing on the tail re-converts exactly one message, never the transcript.
const runtimeMessages = useMemo(
() =>
buildRuntimeMessages(messages, streaming, {
+ isRunning,
liveTimeline,
liveTranscript,
- turnTimelines,
- turnTranscripts,
+ turnTimelines: coreTranscript.timelines,
+ turnTranscripts: coreTranscript.transcripts,
}),
- [messages, streaming, liveTimeline, liveTranscript, turnTimelines, turnTranscripts]
+ [messages, streaming, isRunning, liveTimeline, liveTranscript, coreTranscript]
);
- // `started` and `streaming` are both in-flight; the row is deleted on
- // completion, so a present lifecycle (other than the cold-boot `interrupted`
- // marker, which has no live driver) means a turn is running.
- const isRunning = lifecycle === 'started' || lifecycle === 'streaming';
-
const onNew = useCallback(
async (message: AppendMessage) => {
const surface = getChatSurface(threadId);
diff --git a/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts
index 07bf2c63e8..d575b6affa 100644
--- a/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts
+++ b/app/src/store/__tests__/chatRuntimeSlice.derived.thunk.test.ts
@@ -46,7 +46,7 @@ beforeEach(() => {
});
describe('fetchAndHydrateDerivedTranscript', () => {
- it('hydrates settled-turn trails from the projection, skipping the newest turn', async () => {
+ it('hydrates every settled trail when no request is actively streaming', async () => {
const store = configureStore({ reducer });
mockThreadApi.getDerivedTranscript.mockResolvedValueOnce(
page(
@@ -66,10 +66,9 @@ describe('fetchAndHydrateDerivedTranscript', () => {
expect(mockThreadApi.getTurnStateHistory).not.toHaveBeenCalled();
const timelines = store.getState().turnTimelinesByThread['thread-1'];
const transcripts = store.getState().turnTranscriptsByThread['thread-1'];
- // Newest turn (req-new) is skipped — rendered by the live anchor.
- expect(Object.keys(transcripts)).toEqual(['req-old']);
+ expect(Object.keys(transcripts)).toEqual(['req-old', 'req-new']);
expect(timelines['req-old']).toHaveLength(1);
- expect(transcripts['req-new']).toBeUndefined();
+ expect(transcripts['req-new']).toHaveLength(1);
expect(timelines['req-new']).toBeUndefined();
});
@@ -133,9 +132,9 @@ describe('fetchAndHydrateDerivedTranscript', () => {
await store.dispatch(fetchAndHydrateDerivedTranscript('thread-1'));
const transcripts = store.getState().turnTranscriptsByThread['thread-1'];
- // req-new skipped (newest), req-mid skipped (streaming), req-old kept.
- expect(Object.keys(transcripts)).toEqual(['req-old']);
+ // Only the genuinely streaming request is skipped.
+ expect(Object.keys(transcripts)).toEqual(['req-old', 'req-new']);
expect(transcripts['req-mid']).toBeUndefined();
- expect(transcripts['req-new']).toBeUndefined();
+ expect(transcripts['req-new']).toHaveLength(1);
});
});
diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts
index 102f3691ba..2656796850 100644
--- a/app/src/store/chatRuntimeSlice.ts
+++ b/app/src/store/chatRuntimeSlice.ts
@@ -3,7 +3,7 @@ import debug from 'debug';
import { mapDisplayItems } from '../features/conversations/derived/mapDisplayItems';
import { threadApi } from '../services/api/threadApi';
-import type { DerivedDisplayItem, DerivedTranscriptPage } from '../types/derivedTranscript';
+import type { DerivedTranscriptPage } from '../types/derivedTranscript';
import type { ThreadMessage } from '../types/thread';
import type {
AgentRun,
@@ -870,6 +870,29 @@ function subagentTranscriptItemFromPersisted(
}
function subagentActivityFromPersisted(activity: PersistedSubagentActivity): SubagentActivity {
+ const toolCalls = activity.toolCalls.map(subagentToolCallFromPersisted);
+ const transcript =
+ activity.transcript && activity.transcript.length > 0
+ ? activity.transcript.map(item => {
+ const mapped = subagentTranscriptItemFromPersisted(item);
+ if (mapped.kind !== 'tool') return mapped;
+ const call = toolCalls.find(candidate => candidate.callId === mapped.callId);
+ return call
+ ? { ...mapped, args: call.args, result: call.result, failure: call.failure }
+ : mapped;
+ })
+ : toolCalls.map(call => ({
+ kind: 'tool' as const,
+ iteration: call.iteration,
+ callId: call.callId,
+ toolName: call.toolName,
+ status: call.status,
+ elapsedMs: call.elapsedMs,
+ outputChars: call.outputChars,
+ args: call.args,
+ result: call.result,
+ failure: call.failure,
+ }));
return {
taskId: activity.taskId,
agentId: activity.agentId,
@@ -882,23 +905,12 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub
iterations: activity.iterations,
elapsedMs: activity.elapsedMs,
outputChars: activity.outputChars,
- toolCalls: activity.toolCalls.map(subagentToolCallFromPersisted),
+ toolCalls,
// Prefer the persisted prose transcript (reasoning/narration interleaved
// with tools) so a settled / reloaded run replays its thoughts. Fall back
// to a tool-only rebuild for snapshots written before sub-agent prose was
// persisted (the `transcript` field is absent there).
- transcript:
- activity.transcript && activity.transcript.length > 0
- ? activity.transcript.map(subagentTranscriptItemFromPersisted)
- : activity.toolCalls.map(call => ({
- kind: 'tool' as const,
- iteration: call.iteration,
- callId: call.callId,
- toolName: call.toolName,
- status: call.status,
- elapsedMs: call.elapsedMs,
- outputChars: call.outputChars,
- })),
+ transcript,
};
}
@@ -2382,6 +2394,34 @@ export const fetchAndHydrateTurnState = createAsyncThunk(
}
);
+/**
+ * Wait briefly for the progress bridge to flush its terminal snapshot, then
+ * hydrate it. `chat_done` is delivered by the response presenter while the
+ * bridge may still be consuming the final `TurnCompleted` event; reading once
+ * at that boundary can otherwise install an intermediate, last-round-only
+ * transcript over the just-settled UI.
+ */
+export const fetchAndHydrateCompletedTurnState = createAsyncThunk(
+ 'chatRuntime/fetchAndHydrateCompletedTurnState',
+ async (threadId: string, { dispatch }) => {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ try {
+ const snapshot = await threadApi.getTurnState(threadId);
+ if (snapshot?.lifecycle === 'completed') {
+ dispatch(hydrateRuntimeFromSnapshot({ snapshot }));
+ return snapshot;
+ }
+ } catch (error) {
+ turnStateLog('completed snapshot fetch failed thread=%s err=%O', threadId, error);
+ return null;
+ }
+ await new Promise(resolve => window.setTimeout(resolve, 50));
+ }
+ turnStateLog('completed snapshot did not arrive thread=%s', threadId);
+ return null;
+ }
+);
+
/**
* Fetch the per-turn history for a thread and populate
* {@link ChatRuntimeState.turnTimelinesByThread} so each *past* answer renders
@@ -2466,31 +2506,14 @@ function readChatRuntimeState(state: unknown): ChatRuntimeState | undefined {
}
/**
- * The request ids whose derived trail must NOT be hydrated: the newest turn
- * (rendered as the live "agent insights" anchor from `toolTimelineByThread` /
- * the socket stream, or the `turn_state` snapshot via
- * {@link fetchAndHydrateTurnState}) and any turn currently streaming. Mirrors
- * `fetchAndHydrateTurnHistory`'s `history.slice(1)` newest-turn skip.
+ * The request ids whose derived trail must NOT be hydrated: only turns that
+ * are provably active in this renderer. Do not infer "live" from whichever
+ * request happens to be newest in one transcript file: detached/background
+ * delivery runs in a separate session, so the root file's newest request can
+ * already be historical and must remain visible.
*/
-function liveRequestIdsToSkip(
- state: unknown,
- threadId: string,
- items: DerivedDisplayItem[]
-): Set {
+function liveRequestIdsToSkip(state: unknown, threadId: string): Set {
const skip = new Set();
- // Newest turn = first request id encountered walking newest-first.
- for (const item of items) {
- const rid =
- item.kind === 'turnBoundary'
- ? item.requestId
- : 'requestId' in item
- ? item.requestId
- : undefined;
- if (rid) {
- skip.add(rid);
- break;
- }
- }
const runtime = readChatRuntimeState(state);
const streamingRid = runtime?.streamingAssistantByThread[threadId]?.requestId;
if (streamingRid) skip.add(streamingRid);
@@ -2536,7 +2559,7 @@ export const fetchAndHydrateDerivedTranscript = createAsyncThunk(
await dispatch(fetchAndHydrateTurnHistory(threadId));
return null;
}
- const skipRequestIds = liveRequestIdsToSkip(getState(), threadId, page.items);
+ const skipRequestIds = liveRequestIdsToSkip(getState(), threadId);
const { timelines, transcripts } = mapDisplayItems(page.items, { skipRequestIds });
derivedLog(
'hydrated thread=%s items=%d timelines=%d transcripts=%d skip=%d hasMore=%s',
diff --git a/app/src/types/derivedTranscript.ts b/app/src/types/derivedTranscript.ts
index e683ee6985..c4e5743739 100644
--- a/app/src/types/derivedTranscript.ts
+++ b/app/src/types/derivedTranscript.ts
@@ -6,7 +6,7 @@
* The Rust core projects the append-only `session_raw/*.jsonl` source of truth
* into typed **display items** in the frontend's chat vocabulary. Phase C maps
* these onto the existing settled-turn renderers (`PastTurnInsights` /
- * `ProcessingTranscriptView` / `ToolTimelineBlock` / `SubagentActivityBlock`)
+ * `ProcessingTranscriptView` / assistant-ui tool and delegation cards)
* via `features/conversations/derived/mapDisplayItems.ts`.
*
* Serde is camelCase on the wire, so every field here is camelCase and mirrors
diff --git a/app/test/playwright/specs/chat-harness-subagent.spec.ts b/app/test/playwright/specs/chat-harness-subagent.spec.ts
index 65d0018f82..0655ee6720 100644
--- a/app/test/playwright/specs/chat-harness-subagent.spec.ts
+++ b/app/test/playwright/specs/chat-harness-subagent.spec.ts
@@ -3,6 +3,7 @@ import { expect, type Page, test } from '@playwright/test';
import { agentMessageText } from '../helpers/chat-locators';
import {
bootAuthenticatedPage,
+ callCoreRpc,
dismissWalkthroughIfPresent,
waitForAppReady,
} from '../helpers/core-rpc';
@@ -12,21 +13,45 @@ const USER_ID = 'pw-chat-subagent';
const PROMPT = 'Research the answer to life and tell me a marker phrase.';
const CANARY_FINAL = 'subagent-canary-final-7afe2';
const RESEARCHER_REPLY = 'The researcher answer is 42.';
+const PARENT_THINKING = 'Parent trace: delegate the factual lookup before synthesizing.';
+const PARENT_NARRATION = 'I am delegating the factual lookup now.';
+const CHILD_THINKING = 'Child trace: isolate the requested marker before replying.';
+const FINAL_THINKING = 'Parent trace: verify the delegated finding and answer.';
const KEYWORD_RESPONSES = [
{ keyword: "Search the user's memory tree", content: 'No relevant memory.' },
{
keyword: PROMPT,
- content: '',
- toolCalls: [
+ streamScript: [
+ { thinking: PARENT_THINKING },
+ { text: PARENT_NARRATION },
{
- id: 'call_research_1',
- name: 'research',
- arguments: JSON.stringify({ prompt: 'Tell me a marker phrase' }),
+ toolCall: {
+ id: 'call_research_1',
+ name: 'research',
+ arguments: JSON.stringify({ prompt: 'Tell me a marker phrase' }),
+ },
},
+ { finish: 'tool_calls' },
+ ],
+ },
+ {
+ // `spawn_async_subagent` wraps the requested task in its background-run
+ // contract, so the latest child user message is the rendered handoff, not
+ // the raw tool argument.
+ keyword: 'Run this task without requiring attention from the parent or user',
+ streamScript: [{ thinking: CHILD_THINKING }, { text: RESEARCHER_REPLY }, { finish: 'stop' }],
+ },
+ {
+ // Detached completion is delivered to the parent as a fresh background
+ // notification turn; match that stable framing rather than depending on
+ // exactly how the result body is quoted inside it.
+ keyword: 'background sub-agent finished while you were busy',
+ streamScript: [
+ { thinking: FINAL_THINKING },
+ { text: `Done. The result is: ${CANARY_FINAL}` },
+ { finish: 'stop' },
],
},
- { keyword: 'Tell me a marker phrase', content: RESEARCHER_REPLY },
- { keyword: RESEARCHER_REPLY, content: `Done. The result is: ${CANARY_FINAL}` },
];
interface MockRequest {
@@ -155,6 +180,7 @@ interface DiagnosticsSnapshot {
phase: string | null;
toolTimelineNames: string[];
toolTimelineIds: string[];
+ turnTranscriptTexts: Record;
messageCount: number;
lastAssistantText: string | null;
};
@@ -216,6 +242,7 @@ async function diagnosticsSnapshot(page: Page): Promise {
chatRuntime?: {
inferenceStatusByThread?: Record;
toolTimelineByThread?: Record>;
+ turnTranscriptsByThread?: Record>>;
};
thread?: {
messagesByThread?: Record>;
@@ -233,6 +260,10 @@ async function diagnosticsSnapshot(page: Page): Promise {
currentThreadId && state?.chatRuntime?.toolTimelineByThread?.[currentThreadId]
? state.chatRuntime.toolTimelineByThread[currentThreadId]
: [];
+ const turnTranscripts =
+ currentThreadId && state?.chatRuntime?.turnTranscriptsByThread?.[currentThreadId]
+ ? state.chatRuntime.turnTranscriptsByThread[currentThreadId]
+ : {};
const messages =
currentThreadId && state?.thread?.messagesByThread?.[currentThreadId]
? state.thread.messagesByThread[currentThreadId]
@@ -242,6 +273,12 @@ async function diagnosticsSnapshot(page: Page): Promise {
phase,
toolTimelineNames: timeline.map(entry => entry?.name ?? ''),
toolTimelineIds: timeline.map(entry => entry?.id ?? ''),
+ turnTranscriptTexts: Object.fromEntries(
+ Object.entries(turnTranscripts).map(([requestId, items]) => [
+ requestId,
+ items.map(item => item.text ?? ''),
+ ])
+ ),
messageCount: messages.length,
lastAssistantText:
typeof lastAssistant?.content === 'string' ? lastAssistant.content.slice(0, 240) : null,
@@ -258,6 +295,7 @@ function formatDiagnostics(snapshot: DiagnosticsSnapshot): string {
`matchedKeywords=${JSON.stringify(snapshot.matchedKeywords)}`,
`runtime.phase=${snapshot.runtime.phase ?? ''}`,
`runtime.toolTimelineNames=${JSON.stringify(snapshot.runtime.toolTimelineNames)}`,
+ `runtime.turnTranscriptTexts=${JSON.stringify(snapshot.runtime.turnTranscriptTexts)}`,
`runtime.messageCount=${snapshot.runtime.messageCount}`,
`runtime.lastAssistantText=${JSON.stringify(snapshot.runtime.lastAssistantText)}`,
`completionProbes=${JSON.stringify(
@@ -292,7 +330,7 @@ test.describe('Chat Harness - Subagent', () => {
}
});
- test('delegates to a subagent and persists the final orchestrator text', async ({ page }) => {
+ test('renders and rehydrates the full delegated-turn trace', async ({ page }) => {
test.setTimeout(150_000);
await resetMock();
@@ -301,7 +339,7 @@ test.describe('Chat Harness - Subagent', () => {
await setMockBehavior('llmStreamChunkDelayMs', '10');
await openChat(page);
- await createNewThread(page);
+ const threadId = await createNewThread(page);
await sendMessage(page, PROMPT);
// Three LLM hits are expected: orchestrator-1 (delegates), researcher
@@ -312,18 +350,46 @@ test.describe('Chat Harness - Subagent', () => {
await expect.poll(completionRequestCount, { timeout: 90_000 }).toBeGreaterThanOrEqual(3);
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 30_000 });
- const runtimeSnapshot = await diagnosticsSnapshot(page);
- expect(
- runtimeSnapshot.runtime.phase === 'subagent' ||
- runtimeSnapshot.runtime.toolTimelineNames.some(name => name.startsWith('subagent:')) ||
- runtimeSnapshot.runtime.toolTimelineIds.some(id => id.includes(':subagent:')),
- `expected runtime to show a subagent delegation, got:\n ${formatDiagnostics(
- runtimeSnapshot
- )}`
- ).toBe(true);
-
- // Re-assert after the runtime probe so the persisted message survives the
- // turn-completion store transition rather than only being visible mid-stream.
+ // Re-assert after completion so the persisted message survives the
+ // turn-settlement transition rather than only being visible mid-stream.
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 15_000 });
+
+ // The trace belongs to assistant-ui's message parts—there is no parallel
+ // legacy timeline surface.
+ const finalMessage = page.getByTestId('agent-message').filter({ hasText: CANARY_FINAL }).last();
+ await expect(finalMessage).toBeVisible({ timeout: 15_000 });
+ const finalReasoning = finalMessage.getByRole('button', { name: /Reasoning/ });
+ if ((await finalReasoning.getAttribute('aria-expanded')) !== 'true')
+ await finalReasoning.click();
+ await expect(finalMessage.getByText(FINAL_THINKING, { exact: true })).toBeVisible();
+
+ // Reloading removes the live socket and Redux stream. The same visual
+ // trace must rehydrate from persisted transcript/turn-state data.
+ await page.reload();
+ await waitForAppReady(page);
+ await page.goto('/#/chat');
+ const restoredThread = page.getByTestId(`thread-row-${threadId}`);
+ await expect(restoredThread).toBeVisible({ timeout: 15_000 });
+ await restoredThread.click({ force: true });
+ await expect.poll(() => selectedThreadId(page), { timeout: 15_000 }).toBe(threadId);
+ const derived = await callCoreRpc('openhuman.threads_transcript_get', {
+ thread_id: threadId,
+ limit: 500,
+ });
+ expect(JSON.stringify(derived)).toContain(PARENT_THINKING);
+ const restoredMessage = page
+ .getByTestId('agent-message')
+ .filter({ has: page.getByTestId('assistant-ui-subagent-call') })
+ .last();
+ await expect(restoredMessage).toBeVisible({ timeout: 20_000 });
+ const subagentCall = page.getByTestId('assistant-ui-subagent-call').first();
+ await expect(subagentCall).toBeVisible();
+ const subagentTrigger = subagentCall.getByRole('button').first();
+ await expect(subagentTrigger).toHaveAttribute('aria-expanded', 'false');
+ await expect(subagentCall.getByTestId('subagent-activity')).toHaveCount(0);
+ await subagentTrigger.click();
+ await expect(subagentTrigger).toHaveAttribute('aria-expanded', 'true');
+ await expect(subagentCall.getByTestId('subagent-activity')).toContainText(CHILD_THINKING);
+ await expect(subagentCall.getByTestId('subagent-activity')).toContainText(RESEARCHER_REPLY);
});
});
diff --git a/app/test/playwright/specs/chat-scroll-stability.spec.ts b/app/test/playwright/specs/chat-scroll-stability.spec.ts
new file mode 100644
index 0000000000..9f0a2bda7b
--- /dev/null
+++ b/app/test/playwright/specs/chat-scroll-stability.spec.ts
@@ -0,0 +1,98 @@
+import { expect, test } from '@playwright/test';
+
+import {
+ bootAuthenticatedPage,
+ callCoreRpc,
+ dismissWalkthroughIfPresent,
+ waitForAppReady,
+} from '../helpers/core-rpc';
+
+const USER_ID = 'pw-chat-scroll-stability';
+const TURN_COUNT = 18;
+
+test.describe('Chat scroll paint stability', () => {
+ test('keeps every message laid out while scrolling a long transcript', async ({ page }) => {
+ let transcriptRpcCount = 0;
+ page.on('request', request => {
+ if (request.postData()?.includes('openhuman.threads_transcript_get')) {
+ transcriptRpcCount += 1;
+ }
+ });
+ await bootAuthenticatedPage(page, USER_ID, '/chat');
+
+ const created = await callCoreRpc<{ data: { id: string } }>('openhuman.threads_create_new');
+ const threadId = created.data.id;
+ const base = Date.parse('2026-08-31T12:00:00.000Z');
+ for (let turn = 0; turn < TURN_COUNT; turn += 1) {
+ for (const sender of ['user', 'agent'] as const) {
+ const index = turn * 2 + (sender === 'agent' ? 1 : 0);
+ await callCoreRpc('openhuman.threads_message_append', {
+ thread_id: threadId,
+ message: {
+ id: `scroll-${sender}-${turn}`,
+ sender,
+ type: 'text',
+ createdAt: new Date(base + index * 1000).toISOString(),
+ extraMetadata: {},
+ content:
+ sender === 'user'
+ ? `Question ${turn}: explain this part of the long transcript.`
+ : `Answer ${turn}\n\nThis paragraph has deliberately varied content so its measured height is not a guessed placeholder.\n\n- detail one\n- detail two\n- detail three`,
+ },
+ });
+ }
+ }
+
+ await page.reload();
+ await waitForAppReady(page);
+ await page.goto('/#/chat');
+ await dismissWalkthroughIfPresent(page);
+ const row = page.getByTestId(`thread-row-${threadId}`);
+ await expect(row).toBeVisible({ timeout: 20_000 });
+ await row.click({ force: true });
+
+ const roots = page.locator(
+ '[data-slot="aui_assistant-message-root"], [data-slot="aui_user-message-root"]'
+ );
+ await expect(roots).toHaveCount(TURN_COUNT * 2, { timeout: 20_000 });
+ expect(
+ await roots.evaluateAll(elements =>
+ elements.every(
+ element =>
+ !element.className.includes('content-visibility') &&
+ !element.className.includes('contain-intrinsic-size')
+ )
+ )
+ ).toBe(true);
+
+ const viewport = page.locator('[data-slot="aui_thread-viewport"]');
+ const initial = await viewport.evaluate(element => ({
+ height: element.scrollHeight,
+ messages: element.querySelectorAll(
+ '[data-slot="aui_assistant-message-root"], [data-slot="aui_user-message-root"]'
+ ).length,
+ }));
+ const rpcCountBeforeScroll = transcriptRpcCount;
+
+ for (const fraction of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) {
+ const metrics = await viewport.evaluate(async (element, nextFraction) => {
+ element.scrollTop = (element.scrollHeight - element.clientHeight) * nextFraction;
+ await new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
+ );
+ return {
+ height: element.scrollHeight,
+ messages: element.querySelectorAll(
+ '[data-slot="aui_assistant-message-root"], [data-slot="aui_user-message-root"]'
+ ).length,
+ };
+ }, fraction);
+ expect(metrics).toEqual(initial);
+ // The surface renders `Loading conversation` with no ellipsis; the old
+ // locator matched nothing, so `toHaveCount(0)` passed without ever
+ // checking the loading element.
+ await expect(page.getByText('Loading conversation')).toHaveCount(0);
+ }
+ expect(transcriptRpcCount).toBe(rpcCountBeforeScroll);
+ });
+});
diff --git a/app/test/playwright/specs/chat-tool-call-flow.spec.ts b/app/test/playwright/specs/chat-tool-call-flow.spec.ts
index 5d697dc318..5cec18ff95 100644
--- a/app/test/playwright/specs/chat-tool-call-flow.spec.ts
+++ b/app/test/playwright/specs/chat-tool-call-flow.spec.ts
@@ -11,6 +11,8 @@ const MOCK_ADMIN_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_PORT || '18473'
const USER_ID = 'pw-chat-tool-call';
const PROMPT = 'Fetch the contents of https://example.com for me.';
const CANARY_FINAL = 'canary-tool-call-fetched-a1b2c3';
+const CANARY_SECOND_PARAGRAPH = 'The entire answer must stay in this same assistant bubble.';
+const FINAL_RESPONSE = `Here is the fetched content: ${CANARY_FINAL}\n\n${CANARY_SECOND_PARAGRAPH}`;
const FORCED_RESPONSES = [
{
content: '',
@@ -22,7 +24,7 @@ const FORCED_RESPONSES = [
},
],
},
- { content: `Here is the fetched content: ${CANARY_FINAL}` },
+ { content: FINAL_RESPONSE },
];
interface MockRequest {
@@ -171,6 +173,28 @@ test.describe('Chat Tool Call Flow', () => {
await sendMessage(page, PROMPT);
await expect(agentMessageText(page, CANARY_FINAL)).toBeVisible({ timeout: 40_000 });
+ const finalBubble = page.getByTestId('agent-message').filter({ hasText: CANARY_FINAL });
+ await expect(finalBubble).toHaveCount(1);
+ await expect(finalBubble).toContainText(CANARY_SECOND_PARAGRAPH);
+ await expect(page.getByText(CANARY_SECOND_PARAGRAPH, { exact: true })).toHaveCount(1);
+
+ // Regression: completed tool/reasoning arrays remain in Redux briefly, but
+ // they must not create a synthetic running tail after the final answer.
+ await expect(page.getByLabel('Assistant is working')).toHaveCount(0);
+ await expect(page.getByText('running', { exact: true })).toHaveCount(0);
+
+ // Tool activity belongs to assistant-ui and renders as a readable card,
+ // never through the removed legacy Agentic task insights timeline.
+ await expect(page.getByTestId('agent-task-insights')).toHaveCount(0);
+ await expect(page.getByTestId('assistant-ui-tool-call')).toHaveCount(1);
+ const toolCard = page.getByTestId('assistant-ui-tool-call');
+ await expect(toolCard).toBeVisible();
+ await expect(toolCard).toContainText('Fetched from the web');
+ await expect(toolCard).not.toContainText('running');
+ const toolTrigger = toolCard.getByRole('button').first();
+ if ((await toolTrigger.getAttribute('aria-expanded')) !== 'true') await toolTrigger.click();
+ await expect(toolCard.getByText('Output', { exact: true })).toBeVisible();
+ await expect(toolCard.getByRole('link', { name: 'https://example.com/' })).toBeVisible();
await expect
.poll(
diff --git a/app/test/playwright/specs/connections-tab-deeplinks.spec.ts b/app/test/playwright/specs/connections-tab-deeplinks.spec.ts
index 684c9533bc..54f4bf64e1 100644
--- a/app/test/playwright/specs/connections-tab-deeplinks.spec.ts
+++ b/app/test/playwright/specs/connections-tab-deeplinks.spec.ts
@@ -202,9 +202,7 @@ test.describe('Connections — the /skills back-compat redirect', () => {
await expectSelectedTab(page, 'mcp');
});
- test('/skills?tab=mcp#fragment forwards the fragment as well as the query', async ({
- page,
- }) => {
+ test('/skills?tab=mcp#fragment forwards the fragment as well as the query', async ({ page }) => {
// `ForwardSearch` appends `hash` as well as `search`, and nothing here
// exercised that half. `AppRoutes.skills.test.tsx` does assert
// `loc.hash === '#section-mcp'`, but under `MemoryRouter`, which never
@@ -213,12 +211,7 @@ test.describe('Connections — the /skills back-compat redirect', () => {
// (`#/connections?tab=mcp#section-mcp`). That is the part only a browser
// can answer, and it is the mechanism `/webhooks` relies on for
// `#delivery-3`-style deep links.
- await openRoute(
- page,
- 'pw-skills-hash-forward',
- '/skills?tab=mcp#section-mcp',
- '/connections'
- );
+ await openRoute(page, 'pw-skills-hash-forward', '/skills?tab=mcp#section-mcp', '/connections');
await expect.poll(() => currentHash(page), { timeout: 15_000 }).toContain('tab=mcp');
expect(await currentHash(page)).toContain('#section-mcp');
await expectSelectedTab(page, 'mcp');
diff --git a/app/test/playwright/specs/core-rpc-bearer-401.spec.ts b/app/test/playwright/specs/core-rpc-bearer-401.spec.ts
index 57854a861b..eaabe1cae7 100644
--- a/app/test/playwright/specs/core-rpc-bearer-401.spec.ts
+++ b/app/test/playwright/specs/core-rpc-bearer-401.spec.ts
@@ -99,7 +99,6 @@ test.describe('Core RPC bearer 401 — recovery, not logout', () => {
expect(seenAuth[1]).toContain(ROTATED);
expect(seenAuth[1]).not.toEqual(seenAuth[0]);
-
// (3) The session survives. Without #5876 `clearSession()` wipes the auth
// profile and the app falls back to the signed-out surface. Assert the
// embeddings panel actually rendered rather than merely that the hash is
diff --git a/app/test/playwright/specs/embeddings-setup-modal.spec.ts b/app/test/playwright/specs/embeddings-setup-modal.spec.ts
index dfc2888621..bdd333c022 100644
--- a/app/test/playwright/specs/embeddings-setup-modal.spec.ts
+++ b/app/test/playwright/specs/embeddings-setup-modal.spec.ts
@@ -78,7 +78,11 @@ test.describe('Embeddings setup — Test connection for a custom endpoint', () =
// and no unavailable-reason text is shown for a non-custom provider.
await openEmbeddingsTab(page, 'pw-embed-openai');
- await page.getByRole('radio').filter({ hasText: /OpenAI/ }).first().click();
+ await page
+ .getByRole('radio')
+ .filter({ hasText: /OpenAI/ })
+ .first()
+ .click();
const testButton = page.getByRole('button', { name: TEST_CONNECTION });
await expect(testButton).toBeVisible({ timeout: 15_000 });
diff --git a/app/test/playwright/specs/settings-profiles-crud.spec.ts b/app/test/playwright/specs/settings-profiles-crud.spec.ts
index fdbccf3247..2ddae4b777 100644
--- a/app/test/playwright/specs/settings-profiles-crud.spec.ts
+++ b/app/test/playwright/specs/settings-profiles-crud.spec.ts
@@ -248,11 +248,7 @@ test.describe('Agent profiles — a failing action shows the reason', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
- body: JSON.stringify({
- jsonrpc: '2.0',
- id: body.id,
- error: { code: -32000, message },
- }),
+ body: JSON.stringify({ jsonrpc: '2.0', id: body.id, error: { code: -32000, message } }),
});
return;
}
diff --git a/app/test/playwright/specs/settings-theme-import-validation.spec.ts b/app/test/playwright/specs/settings-theme-import-validation.spec.ts
index 9679e37ded..89263c19a0 100644
--- a/app/test/playwright/specs/settings-theme-import-validation.spec.ts
+++ b/app/test/playwright/specs/settings-theme-import-validation.spec.ts
@@ -90,11 +90,7 @@ test.describe('Theme Studio — import validation', () => {
}
test('still accepts a theme carrying a single colour token', async ({ page }) => {
- const valid = JSON.stringify({
- name: 'Minimal',
- isDark: false,
- colors: { surface: '1 2 3' },
- });
+ const valid = JSON.stringify({ name: 'Minimal', isDark: false, colors: { surface: '1 2 3' } });
await attemptImport(page, valid);
diff --git a/app/test/playwright/specs/token-usage-load-failure.spec.ts b/app/test/playwright/specs/token-usage-load-failure.spec.ts
index e73eda9ff3..0a304506dc 100644
--- a/app/test/playwright/specs/token-usage-load-failure.spec.ts
+++ b/app/test/playwright/specs/token-usage-load-failure.spec.ts
@@ -43,11 +43,7 @@ async function failMethod(page: Page, method: string, message: string) {
await route.fulfill({
status: 200,
contentType: 'application/json',
- body: JSON.stringify({
- jsonrpc: '2.0',
- id: body.id,
- error: { code: -32000, message },
- }),
+ body: JSON.stringify({ jsonrpc: '2.0', id: body.id, error: { code: -32000, message } }),
});
return;
}
diff --git a/scripts/mock-api/routes/__tests__/llm.test.mjs b/scripts/mock-api/routes/__tests__/llm.test.mjs
index 5152105f11..b0d089c6c4 100644
--- a/scripts/mock-api/routes/__tests__/llm.test.mjs
+++ b/scripts/mock-api/routes/__tests__/llm.test.mjs
@@ -200,6 +200,53 @@ test("streams reasoning deltas for reasoning-family models", async () => {
assert.match(ctx.res.body, /data: \[DONE\]/);
});
+test("keyword stream scripts preserve reasoning and tool-event order", async () => {
+ setMockBehaviors(
+ {
+ llmKeywordRules: JSON.stringify([
+ {
+ keyword: "trace this",
+ streamScript: [
+ { thinking: "first reason" },
+ { text: "then narrate" },
+ {
+ toolCall: {
+ id: "call_trace_1",
+ name: "web_search",
+ arguments: '{"q":"trace"}',
+ },
+ },
+ { finish: "tool_calls" },
+ ],
+ },
+ ]),
+ llmStreamChunkDelayMs: "0",
+ },
+ "replace",
+ );
+
+ const ctx = makeCtx({
+ parsedBody: {
+ model: "e2e-mock-model",
+ stream: true,
+ messages: [{ role: "user", content: "please trace this turn" }],
+ },
+ });
+
+ assert.equal(handleLlmCompletions(ctx), true);
+ // `safeDelayMs` deliberately normalizes zero to the default cadence, so
+ // wait for this tiny four-event script to finish rather than sampling it
+ // halfway through its SSE writes.
+ await new Promise((resolve) => setTimeout(resolve, 180));
+ const thinking = ctx.res.body.indexOf("first reason");
+ const narration = ctx.res.body.indexOf("then narrate");
+ const tool = ctx.res.body.indexOf("call_trace_1");
+ assert.ok(thinking >= 0, "keyword stream script should emit reasoning_content");
+ assert.ok(narration > thinking, "narration should follow reasoning");
+ assert.ok(tool > narration, "tool call should follow narration");
+ assert.match(ctx.res.body, /data: \[DONE\]/);
+});
+
test("returns tool calls for agentic models and resolves follow-up turns", () => {
const first = makeCtx({
parsedBody: {
diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs
index 649b002709..5ed2c3f294 100644
--- a/scripts/mock-api/routes/llm.mjs
+++ b/scripts/mock-api/routes/llm.mjs
@@ -316,10 +316,16 @@ function handleStreamingCompletion({
if (!rule || typeof rule.keyword !== "string") continue;
if (probe.includes(rule.keyword.toLowerCase())) {
const rendered = applyDynamicPlaceholdersToResponse(rule, parsedBody);
- script = defaultStreamScript({
- content: rendered.content,
- toolCalls: rendered.toolCalls,
- });
+ // Keyword routes need the same control over event ordering as a
+ // global stream script. That lets browser tests exercise a real
+ // reasoning -> narration -> tool sequence without a FIFO script
+ // being consumed by an unrelated completion.
+ script = Array.isArray(rendered.streamScript)
+ ? rendered.streamScript
+ : defaultStreamScript({
+ content: rendered.content,
+ toolCalls: rendered.toolCalls,
+ });
break;
}
}
diff --git a/src/openhuman/agent/harness/session/transcript_part_01.rs b/src/openhuman/agent/harness/session/transcript_part_01.rs
index 41e1e8e3a2..0dddcefa4d 100644
--- a/src/openhuman/agent/harness/session/transcript_part_01.rs
+++ b/src/openhuman/agent/harness/session/transcript_part_01.rs
@@ -434,6 +434,30 @@ fn build_message_line(
Some((failed, detail)) => (failed, detail),
None => (false, None),
};
+ let message_reasoning = (msg.role == "assistant")
+ .then(|| {
+ extra_metadata
+ .as_ref()
+ .and_then(|meta| {
+ meta.get(crate::openhuman::agent::message_convert::REASONING_EXT_KEY)
+ })
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string)
+ })
+ .flatten();
+ let native_envelope = (msg.role == "assistant")
+ .then(|| serde_json::from_str::(&msg.content).ok())
+ .flatten();
+ let envelope_reasoning = native_envelope
+ .as_ref()
+ .and_then(|value| value.get("reasoning_content"))
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string);
+ let envelope_tool_calls = native_envelope
+ .as_ref()
+ .and_then(|value| value.get("tool_calls"))
+ .and_then(|value| serde_json::from_value::>(value.clone()).ok())
+ .filter(|calls| !calls.is_empty());
MessageLine {
id: msg.id.clone(),
role: msg.role.clone(),
@@ -442,13 +466,17 @@ fn build_message_line(
provider: assistant_usage.map(|tu| tu.provider.clone()),
model: assistant_usage.map(|tu| tu.model.clone()),
usage: assistant_usage.map(|tu| tu.usage.clone()),
- reasoning_content: assistant_usage.and_then(|tu| tu.reasoning_content.clone()),
- tool_calls: assistant_usage.and_then(|tu| {
- if tu.tool_calls.is_empty() {
- None
- } else {
- Some(tu.tool_calls.clone())
- }
+ reasoning_content: message_reasoning
+ .or(envelope_reasoning)
+ .or_else(|| assistant_usage.and_then(|tu| tu.reasoning_content.clone())),
+ tool_calls: envelope_tool_calls.or_else(|| {
+ assistant_usage.and_then(|tu| {
+ if tu.tool_calls.is_empty() {
+ None
+ } else {
+ Some(tu.tool_calls.clone())
+ }
+ })
}),
iteration: assistant_usage.map(|tu| tu.iteration),
ts: assistant_usage.map(|tu| tu.ts.clone()),
diff --git a/src/openhuman/agent/harness/session/transcript_part_02.rs b/src/openhuman/agent/harness/session/transcript_part_02.rs
index 66c84d2e9e..8737aab9b2 100644
--- a/src/openhuman/agent/harness/session/transcript_part_02.rs
+++ b/src/openhuman/agent/harness/session/transcript_part_02.rs
@@ -453,10 +453,28 @@ pub fn read_transcript_display(path: &Path) -> Result
/// transcript without accidentally folding delegated worker transcripts
/// into the main chat timeline.
pub fn find_root_transcript_for_thread(workspace_dir: &Path, thread_id: &str) -> Option {
- raw_session_dirs(workspace_dir)
- .into_iter()
- .filter_map(|raw_dir| find_root_transcript_for_thread_in_dir(&raw_dir, thread_id))
- .max_by(|left, right| left.file_name().cmp(&right.file_name()))
+ find_root_transcripts_for_thread(workspace_dir, thread_id).pop()
+}
+
+pub fn find_root_transcripts_for_thread(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Vec {
+ let mut matches = Vec::new();
+ for raw_dir in raw_session_dirs(workspace_dir) {
+ matches.extend(root_transcripts_for_thread_in_dir(&raw_dir, thread_id));
+ }
+ // Already chronological within each directory (see
+ // `root_transcripts_for_thread_in_dir`); re-key across directories on the
+ // same `created` stamp rather than the file name.
+ matches.sort_by_cached_key(|path| {
+ let created = read_transcript(path)
+ .ok()
+ .map(|transcript| transcript.meta.created)
+ .unwrap_or_default();
+ (created, path.clone())
+ });
+ matches
}
fn raw_session_dirs(workspace_dir: &Path) -> Vec {
@@ -478,13 +496,26 @@ fn raw_session_dirs(workspace_dir: &Path) -> Vec {
}
pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Option {
+ root_transcripts_for_thread_in_dir(raw_dir, thread_id).pop()
+}
+
+fn root_transcripts_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -> Vec {
let thread_id = thread_id.trim();
if thread_id.is_empty() {
- return None;
+ return Vec::new();
}
- let entries = fs::read_dir(raw_dir).ok()?;
- let mut matches: Vec = entries
+ let Ok(entries) = fs::read_dir(raw_dir) else {
+ return Vec::new();
+ };
+ // Keyed by `meta.created` so the order is chronological rather than
+ // lexicographic. Modern stems are `{unix_ts}_{agent_id}` and sort the same
+ // either way, but a legacy `{agent}_{index}` root encodes no time at all —
+ // and because digits sort before letters, every legacy root sorted *after*
+ // every modern one regardless of when it was written. `project_from_files`
+ // concatenates these in order, so that reordered the rendered view and
+ // could attach a sub-agent trail to the wrong turn.
+ let mut matches: Vec<(String, PathBuf)> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| {
@@ -494,20 +525,25 @@ pub fn find_root_transcript_for_thread_in_dir(raw_dir: &Path, thread_id: &str) -
.and_then(|s| s.to_str())
.is_some_and(|stem| !stem.contains("__"))
})
- .filter(|path| match read_transcript(path) {
- Ok(transcript) => transcript.meta.thread_id.as_deref() == Some(thread_id),
+ .filter_map(|path| match read_transcript(&path) {
+ Ok(transcript) if transcript.meta.thread_id.as_deref() == Some(thread_id) => {
+ Some((transcript.meta.created.clone(), path))
+ }
+ Ok(_) => None,
Err(err) => {
log::warn!(
"[transcript] skipping unreadable root transcript candidate {}: {err}",
path.display()
);
- false
+ None
}
})
.collect();
- matches.sort();
- matches.pop()
+ // Path is the tiebreak so the order stays total and deterministic when two
+ // transcripts share a `created` stamp.
+ matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
+ matches.into_iter().map(|(_, path)| path).collect()
}
/// Aggregated token/cost usage for a chat thread, summed across **all** of the
diff --git a/src/openhuman/agent/harness/session/transcript_tests_part_01_tests.rs b/src/openhuman/agent/harness/session/transcript_tests_part_01_tests.rs
index 828b62caa3..5882e4f9a7 100644
--- a/src/openhuman/agent/harness/session/transcript_tests_part_01_tests.rs
+++ b/src/openhuman/agent/harness/session/transcript_tests_part_01_tests.rs
@@ -1,5 +1,20 @@
use super::*;
+#[test]
+fn intermediate_assistant_reasoning_is_lifted_without_turn_usage() {
+ let mut message = ChatMessage::assistant("I will inspect the repository.");
+ message.extra_metadata = Some(serde_json::json!({
+ crate::openhuman::agent::message_convert::REASONING_EXT_KEY:
+ "First identify the relevant files."
+ }));
+
+ let line = build_message_line(&message, None, Some("request-1"), false);
+ assert_eq!(
+ line.reasoning_content.as_deref(),
+ Some("First identify the relevant files.")
+ );
+}
+
#[test]
fn round_trip_produces_byte_identical_messages() {
let dir = TempDir::new().unwrap();
diff --git a/src/openhuman/agent/harness/session/transcript_tests_part_03_tests.rs b/src/openhuman/agent/harness/session/transcript_tests_part_03_tests.rs
index 5baf739b9e..96f80b02a6 100644
--- a/src/openhuman/agent/harness/session/transcript_tests_part_03_tests.rs
+++ b/src/openhuman/agent/harness/session/transcript_tests_part_03_tests.rs
@@ -53,3 +53,58 @@ fn meta_version_stamped_and_optional() {
"append writer must stamp the schema version on the meta header"
);
}
+
+/// Root transcripts must come back in the order they were written, not in
+/// filename order.
+///
+/// Modern stems are `{unix_ts}_{agent}` and sort identically either way, but a
+/// legacy `{agent}_{index}` root encodes no time at all — and because digits
+/// sort before letters, every legacy root sorted *after* every modern one no
+/// matter when it was written. `project_from_files` concatenates these in
+/// order, so a mis-ordered list reorders the rendered view and can attach a
+/// sub-agent trail to the wrong turn.
+#[test]
+fn root_transcripts_are_ordered_by_creation_not_file_name() {
+ let dir = TempDir::new().unwrap();
+ let raw = dir.path().join("session_raw");
+ std::fs::create_dir_all(&raw).unwrap();
+
+ let write_root = |stem: &str, created: &str| {
+ let mut meta = sample_meta();
+ meta.thread_id = Some("thread-order".into());
+ meta.created = created.into();
+ write_transcript(
+ &raw.join(format!("{stem}.jsonl")),
+ &sample_messages(),
+ &meta,
+ None,
+ )
+ .unwrap();
+ };
+
+ // The legacy root is the OLDEST, but `orchestrator_1` sorts after any
+ // digit-led stem, so a filename sort puts it last.
+ write_root("orchestrator_1", "2026-04-10T09:00:00Z");
+ write_root("1776211200_orchestrator", "2026-04-11T09:00:00Z");
+ write_root("1776297600_orchestrator", "2026-04-12T09:00:00Z");
+
+ let ordered: Vec = find_root_transcripts_for_thread(dir.path(), "thread-order")
+ .into_iter()
+ .map(|path| {
+ path.file_stem()
+ .and_then(|stem| stem.to_str())
+ .unwrap_or_default()
+ .to_string()
+ })
+ .collect();
+
+ assert_eq!(
+ ordered,
+ vec![
+ "orchestrator_1".to_string(),
+ "1776211200_orchestrator".to_string(),
+ "1776297600_orchestrator".to_string(),
+ ],
+ "roots must be chronological; a filename sort would put the legacy root last"
+ );
+}
diff --git a/src/openhuman/threads/transcript_view/cache.rs b/src/openhuman/threads/transcript_view/cache.rs
index f8b9016837..d1dcbfd418 100644
--- a/src/openhuman/threads/transcript_view/cache.rs
+++ b/src/openhuman/threads/transcript_view/cache.rs
@@ -70,8 +70,10 @@ impl TranscriptViewCache {
workspace_dir: &Path,
thread_id: &str,
) -> Option> {
- let (root_path, sub_paths) = project::resolve_files(workspace_dir, thread_id)?;
- let signature: Vec = std::iter::once(file_sig(&root_path))
+ let (root_paths, sub_paths) = project::resolve_files(workspace_dir, thread_id)?;
+ let signature: Vec = root_paths
+ .iter()
+ .map(|path| file_sig(path))
.chain(sub_paths.iter().map(|p| file_sig(p)))
.collect();
@@ -94,7 +96,9 @@ impl TranscriptViewCache {
}
let projected = Arc::new(project::project_from_files(
- thread_id, &root_path, &sub_paths,
+ thread_id,
+ &root_paths,
+ &sub_paths,
));
let mut inner = self.inner.lock().ok()?;
diff --git a/src/openhuman/threads/transcript_view/project.rs b/src/openhuman/threads/transcript_view/project.rs
index ea9aa125d1..324f50d03f 100644
--- a/src/openhuman/threads/transcript_view/project.rs
+++ b/src/openhuman/threads/transcript_view/project.rs
@@ -31,65 +31,94 @@ const DATETIME_PREFIX: &str = "Current Date & Time:";
/// live injector currently only prepends [`DATETIME_PREFIX`].
const CHANNEL_CONTEXT_PREFIX: &str = "[Channel context]";
+type NativeToolCall = (String, String, String);
+type NativeToolEnvelope = (String, Vec);
+
/// Resolve a thread's root transcript, discover its sub-agent siblings, and
/// project everything into display items. Returns `None` when the thread has
/// no root transcript yet (brand-new thread / first turn not persisted).
pub fn project_thread(workspace_dir: &Path, thread_id: &str) -> Option {
- let (root_path, sub_paths) = resolve_files(workspace_dir, thread_id)?;
- Some(project_from_files(thread_id, &root_path, &sub_paths))
+ let (root_paths, sub_paths) = resolve_files(workspace_dir, thread_id)?;
+ Some(project_from_files(thread_id, &root_paths, &sub_paths))
}
/// Resolve the on-disk file set backing a thread's transcript view: the root
/// transcript path plus every sub-agent sibling file. `None` when the thread
/// has no root transcript yet. Exposed so the cache can key on these paths
/// (and their mtimes/lengths) without re-projecting.
-pub fn resolve_files(workspace_dir: &Path, thread_id: &str) -> Option<(PathBuf, Vec)> {
- let root_path = transcript::find_root_transcript_for_thread(workspace_dir, thread_id)?;
- let root_stem = root_path.file_stem()?.to_str()?.to_string();
- let Some(raw_dir) = root_path.parent() else {
- log::warn!(
- "{LOG_PREFIX} resolved root has no parent thread={thread_id} root={}",
- root_path.display()
- );
+pub fn resolve_files(
+ workspace_dir: &Path,
+ thread_id: &str,
+) -> Option<(Vec, Vec)> {
+ let root_paths = transcript::find_root_transcripts_for_thread(workspace_dir, thread_id);
+ if root_paths.is_empty() {
return None;
- };
- let sub_paths = discover_subagent_files(raw_dir, &root_stem);
- Some((root_path, sub_paths))
+ }
+ let mut sub_paths = Vec::new();
+ for root_path in &root_paths {
+ let Some(root_stem) = root_path.file_stem().and_then(|stem| stem.to_str()) else {
+ continue;
+ };
+ let Some(raw_dir) = root_path.parent() else {
+ continue;
+ };
+ sub_paths.extend(discover_subagent_files(raw_dir, root_stem));
+ }
+ sub_paths.sort();
+ sub_paths.dedup();
+ Some((root_paths, sub_paths))
}
/// Project a thread from an already-resolved file set (root + sub-agent
/// siblings). Missing/unreadable files degrade to empty rather than failing.
pub fn project_from_files(
thread_id: &str,
- root_path: &Path,
+ root_paths: &[PathBuf],
sub_paths: &[PathBuf],
) -> ProjectedTranscript {
- let root_stem = root_path
- .file_stem()
- .and_then(|s| s.to_str())
- .unwrap_or_default()
- .to_string();
-
log::debug!(
- "{LOG_PREFIX} projecting thread={thread_id} root={} subagent_files={}",
- root_path.display(),
+ "{LOG_PREFIX} projecting thread={thread_id} roots={} subagent_files={}",
+ root_paths.len(),
sub_paths.len()
);
// Read the root display records once: they feed both the top-level items
// and the per-turn timestamp ranges used to anchor sub-agent trails.
- let (mut items, segments) = match transcript::read_transcript_display(root_path) {
- Ok(d) => (project_records(&d.records), turn_segments(&d.records)),
- Err(err) => {
- log::warn!(
- "{LOG_PREFIX} failed to read root transcript {}: {err}",
- root_path.display()
- );
- (Vec::new(), Vec::new())
+ let mut items = Vec::new();
+ let mut segments = Vec::new();
+ for root_path in root_paths {
+ match transcript::read_transcript_display(root_path) {
+ Ok(display) => {
+ items.extend(project_records(&display.records));
+ segments.extend(turn_segments(&display.records));
+ }
+ Err(err) => {
+ log::warn!(
+ "{LOG_PREFIX} failed to read root transcript {}: {err}",
+ root_path.display()
+ );
+ }
}
- };
+ }
- let subagents = build_subagent_items(sub_paths, &root_stem, 0, &segments);
+ let mut subagents = Vec::new();
+ for root_path in root_paths {
+ let root_stem = root_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or_default();
+ let prefix = format!("{root_stem}__");
+ let siblings: Vec = sub_paths
+ .iter()
+ .filter(|path| {
+ path.file_stem()
+ .and_then(|stem| stem.to_str())
+ .is_some_and(|stem| stem.starts_with(&prefix))
+ })
+ .cloned()
+ .collect();
+ subagents.extend(build_subagent_items(&siblings, root_stem, 0, &segments));
+ }
log::debug!(
"{LOG_PREFIX} projected thread={thread_id} top_level_items={} subagents={}",
items.len(),
@@ -377,17 +406,43 @@ fn project_assistant(
}
}
- let tool_calls = msg
+ let persisted_tool_calls: Vec<(String, String, String)> = msg
.turn_usage
.as_ref()
- .map(|tu| tu.tool_calls.as_slice())
+ .map(|tu| {
+ tu.tool_calls
+ .iter()
+ .map(|call| (call.id.clone(), call.name.clone(), call.arguments.clone()))
+ .collect()
+ })
.unwrap_or_default();
+ let native_envelope = parse_native_tool_envelope(&msg.message.content);
+ // Some native/tinyagents histories predate top-level `turn_usage` lifting
+ // and carry the invocation only inside the provider replay envelope. Use
+ // that canonical call shape rather than degrading the paired result to an
+ // orphan named "tool".
+ let tool_calls = if persisted_tool_calls.is_empty() {
+ native_envelope
+ .as_ref()
+ .map(|(_, calls)| calls.clone())
+ .unwrap_or_default()
+ } else {
+ persisted_tool_calls
+ };
let interim = !tool_calls.is_empty();
+ // Native tool-call turns are persisted as their provider envelope so they
+ // can be replayed byte-faithfully. The display projection needs only the
+ // envelope's visible `content`; rendering/sanitizing the whole JSON object
+ // makes the narration disappear (and risks showing raw tool JSON).
+ let visible_content = native_envelope
+ .map(|(content, _)| content)
+ .unwrap_or_else(|| msg.message.content.clone());
+
// The assistant's prose (if any) shows before its tool calls.
- if !msg.message.content.trim().is_empty() {
+ if !visible_content.trim().is_empty() {
items.push(DisplayItem::AssistantMessage {
- content: msg.message.content.clone(),
+ content: visible_content,
interim,
request_id: msg.request_id.clone(),
model: msg.turn_usage.as_ref().map(|tu| tu.model.clone()),
@@ -395,20 +450,48 @@ fn project_assistant(
});
}
- for call in tool_calls {
- let args = parse_tool_args(&call.arguments);
+ for (call_id, name, arguments) in tool_calls {
+ let args = parse_tool_args(&arguments);
items.push(DisplayItem::ToolCall {
- call_id: call.id.clone(),
- name: call.name.clone(),
+ call_id: call_id.clone(),
+ name,
args,
result: None,
status: ToolCallStatus::Running,
failure: None,
});
- pending.push_back((call.id.clone(), items.len() - 1));
+ pending.push_back((call_id, items.len() - 1));
}
}
+/// Decode the native provider replay envelope embedded in `ChatMessage.content`.
+/// Returns visible assistant prose plus `(id, name, arguments)` calls.
+fn parse_native_tool_envelope(raw: &str) -> Option {
+ let value = serde_json::from_str::(raw).ok()?;
+ let object = value.as_object()?;
+ let calls = object.get("tool_calls")?.as_array()?;
+ let content = match object.get("content") {
+ Some(serde_json::Value::String(content)) => content.clone(),
+ Some(serde_json::Value::Null) | None => String::new(),
+ _ => return None,
+ };
+ let calls = calls
+ .iter()
+ .filter_map(|call| {
+ let call = call.as_object()?;
+ let id = call.get("id")?.as_str()?.to_string();
+ let name = call.get("name")?.as_str()?.to_string();
+ let arguments = match call.get("arguments") {
+ Some(serde_json::Value::String(arguments)) => arguments.clone(),
+ Some(arguments) => arguments.to_string(),
+ None => String::new(),
+ };
+ Some((id, name, arguments))
+ })
+ .collect();
+ Some((content, calls))
+}
+
fn project_tool_result(
msg: &DisplayMessage,
items: &mut Vec,
diff --git a/src/openhuman/threads/transcript_view/transcript_view_tests.rs b/src/openhuman/threads/transcript_view/transcript_view_tests.rs
index bdbb8cb950..2532c95420 100644
--- a/src/openhuman/threads/transcript_view/transcript_view_tests.rs
+++ b/src/openhuman/threads/transcript_view/transcript_view_tests.rs
@@ -123,6 +123,60 @@ fn projects_turn_with_tools_reasoning_and_sanitization() {
}
}
+#[test]
+fn recovers_tool_name_from_native_envelope_without_turn_usage() {
+ let dir = TempDir::new().unwrap();
+ let envelope = serde_json::json!({
+ "content": null,
+ "tool_calls": [{
+ "id": "call-web-1",
+ "name": "web_fetch",
+ "arguments": { "url": "https://example.com" }
+ }]
+ })
+ .to_string();
+ let assistant = serde_json::json!({
+ "role": "assistant",
+ "content": envelope,
+ "request_id": "req-native"
+ })
+ .to_string();
+ let result =
+ r#"{"role":"tool","content":"Example Domain","id":"call-web-1","request_id":"req-native"}"#;
+ let path = write_raw(
+ dir.path(),
+ "101_orchestrator",
+ "thr_native",
+ &[assistant.as_str(), result],
+ );
+ let display = read_transcript_display(&path).unwrap();
+ let items = project_records(&display.records);
+
+ let tool = items
+ .iter()
+ .find_map(|item| match item {
+ DisplayItem::ToolCall {
+ name,
+ args,
+ result,
+ status,
+ ..
+ } => Some((name, args, result, status)),
+ _ => None,
+ })
+ .expect("native envelope tool call projected");
+ assert_eq!(tool.0, "web_fetch");
+ assert_eq!(
+ tool.1
+ .as_ref()
+ .and_then(|args| args.get("url"))
+ .and_then(serde_json::Value::as_str),
+ Some("https://example.com")
+ );
+ assert_eq!(tool.2.as_deref(), Some("Example Domain"));
+ assert_eq!(*tool.3, ToolCallStatus::Success);
+}
+
#[test]
fn projects_compaction_and_interrupted_partial() {
let dir = TempDir::new().unwrap();
diff --git a/src/openhuman/web_chat/presentation.rs b/src/openhuman/web_chat/presentation.rs
index 3db2b671b0..44e14076cb 100644
--- a/src/openhuman/web_chat/presentation.rs
+++ b/src/openhuman/web_chat/presentation.rs
@@ -44,12 +44,12 @@ fn usage_payload(usage: Option<&LastTurnUsage>) -> Option {
})
}
-/// Deliver an agent response to the frontend, applying local-model
-/// presentation (segmentation + reaction) when the model is available.
+/// Deliver one unmodified agent response to the frontend.
///
-/// Always emits at least one `chat_done` event. When the response is
-/// segmented, emits one `chat_segment` per bubble first, then a final
-/// `chat_done` with the full text for deduplication.
+/// Desktop/web chat owns Markdown layout inside one assistant message. Splitting
+/// paragraphs into `chat_segment` messages duplicates tool/reasoning parts and
+/// turns one answer into several bubbles, so this path always emits exactly one
+/// `chat_done` with the model's original text.
pub(crate) async fn deliver_response(
client_id: &str,
thread_id: &str,
@@ -66,8 +66,10 @@ pub(crate) async fn deliver_response(
let user_msg_owned = user_message.to_string();
let reaction_handle = tokio::spawn(async move { try_reaction(&user_msg_owned).await });
- // Segmentation is pure CPU work, runs immediately.
- let segments = segment_for_delivery(full_response);
+ // Keep the response byte-for-byte in one assistant message. The legacy
+ // segmentation helpers remain available to channel-specific callers/tests,
+ // but the interactive web surface must not cut or reformat model output.
+ let segments = [full_response.to_string()];
// Await the reaction result (should already be done or nearly done).
let reaction_emoji = reaction_handle.await.unwrap_or(None);
diff --git a/tests/observability_wallet_expected_e2e.rs b/tests/observability_wallet_expected_e2e.rs
index 48513825fd..368134c4a9 100644
--- a/tests/observability_wallet_expected_e2e.rs
+++ b/tests/observability_wallet_expected_e2e.rs
@@ -281,24 +281,31 @@ fn reporting_a_genuine_wallet_failure_still_emits_error() {
#[cfg(feature = "crash-reporting")]
mod paging {
use super::*;
-
- /// Drive `report_error_or_expected` against an envelope-capturing Sentry
- /// client and return how many events it actually sent.
+ /// Count the Sentry events one call to `report_error_or_expected` produces.
+ ///
+ /// The client is bound to a **private** hub that is current only inside
+ /// [`sentry::Hub::run`]. `sentry::init` instead binds it on the hub every
+ /// test thread's hub is copied from, so while a paging test held a client,
+ /// a sibling test's `report_error_or_expected` on another thread landed in
+ /// this transport too — `left: 2` for a genuine failure, deterministic
+ /// under the product feature set, invisible under the contributor default
+ /// set where this module does not compile.
///
- /// `sentry::init` mutates the process-global hub and cargo runs these
- /// functions on parallel threads, so the critical section is serialized
- /// here rather than by imposing `--test-threads=1` on the whole binary —
- /// the same reasoning, and the same shape, as `observability_smoke.rs`.
+ /// The private hub removes the *client* binding, but it does not remove the
+ /// need to serialise. `sentry-tracing`'s layer sits in the global
+ /// subscriber stack, so while a client is current on this thread a
+ /// `tracing::error!` raised by `capture_reporting` on ANOTHER thread can be
+ /// consumed by that layer instead of reaching its fmt subscriber — the
+ /// capture then comes back empty and
+ /// `reporting_a_genuine_wallet_failure_still_emits_error` fails for a
+ /// reason unrelated to the behaviour under test. That is `main`'s
+ /// `d0509bb17` finding, and it still holds here: an earlier revision of
+ /// this merge dropped the guard on the reasoning that a private hub made it
+ /// redundant, and CI reproduced exactly that failure. Both fixes are
+ /// needed — the private hub for the paging count, the file-wide lock for
+ /// the capture.
fn captured_events_for(message: &str) -> usize {
- // The file-wide lock, not a private one. `sentry::init` below binds a
- // client to the process-global Hub, and with `sentry-tracing` compiled
- // in that changes what a `tracing::error!` on ANY thread is able to
- // reach — including the fmt subscriber the capture tests above install.
- // A lock scoped to this module serialized these two functions against
- // each other and against nothing else, which is what let the capture
- // tests come back empty in the full-suite lane.
let _guard = lock_reporting_state();
-
let transport = sentry::test::TestTransport::new();
let transport_for_factory = transport.clone();
let options = sentry::ClientOptions {
@@ -313,13 +320,15 @@ mod paging {
sample_rate: 1.0,
..sentry::ClientOptions::default()
};
- let _sentry_guard = sentry::init(options);
-
- report_error_or_expected(message, "rpc", "invoke_method", &[]);
-
- sentry::Hub::current()
- .client()
- .map(|c| c.flush(Some(std::time::Duration::from_secs(2))));
+ let client = Arc::new(sentry::Client::from_config(sentry::apply_defaults(options)));
+ let hub = Arc::new(sentry::Hub::new(
+ Some(Arc::clone(&client)),
+ Arc::new(sentry::Scope::default()),
+ ));
+ sentry::Hub::run(hub, || {
+ report_error_or_expected(message, "rpc", "invoke_method", &[]);
+ });
+ client.flush(Some(std::time::Duration::from_secs(2)));
transport.fetch_and_clear_envelopes().len()
}
diff --git a/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs b/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs
index 14ff116460..6842d20320 100644
--- a/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs
+++ b/tests/raw_coverage/channels_bus_presentation_raw_coverage_e2e.rs
@@ -79,16 +79,13 @@ async fn presentation_segments_text_and_delivers_single_bubble_with_citations()
}
#[tokio::test]
-async fn presentation_delivers_segment_events_then_deduping_done_event() {
+async fn presentation_delivers_one_unmodified_done_event() {
let response = [
"First paragraph has enough natural language content to stand alone as a separate chat bubble.",
"Second paragraph also contains enough prose to exercise segmented delivery and delay calculation.",
"Third paragraph ensures the final chat_done event carries the complete response for deduplication.",
]
.join("\n\n");
- let segments = presentation_test_support::segment_for_delivery_for_test(&response);
- assert!(segments.len() >= 2, "expected segmented delivery");
-
let mut rx = subscribe_web_channel_events();
presentation_test_support::deliver_response_for_test(
"round20-client",
@@ -100,30 +97,20 @@ async fn presentation_delivers_segment_events_then_deduping_done_event() {
)
.await;
- let mut seen_segments = 0_u32;
let final_event = timeout(Duration::from_secs(10), async {
loop {
let event = rx.recv().await.expect("presentation event");
if event.request_id != "round20-segmented" {
continue;
}
- match event.event.as_str() {
- "chat_segment" => {
- assert_eq!(event.segment_total, Some(segments.len() as u32));
- assert_eq!(event.segment_index, Some(seen_segments));
- assert!(event.full_response.as_deref().unwrap_or("").len() >= 40);
- seen_segments += 1;
- }
- "chat_done" => break event,
- other => panic!("unexpected presentation event {other}"),
- }
+ assert_eq!(event.event, "chat_done");
+ break event;
}
})
.await
.expect("segmented delivery timeout");
- assert_eq!(seen_segments, segments.len() as u32);
- assert_eq!(final_event.segment_total, Some(segments.len() as u32));
+ assert_eq!(final_event.segment_total, None);
assert_eq!(
final_event.full_response.as_deref(),
Some(response.as_str())
diff --git a/vendor/tinyagents b/vendor/tinyagents
index a53888802e..2b95b98ed0 160000
--- a/vendor/tinyagents
+++ b/vendor/tinyagents
@@ -1 +1 @@
-Subproject commit a53888802e83544c24e973f358862fc58c5f1da6
+Subproject commit 2b95b98ed070d73a2ef44de8fbbf04bb4f51906c