fix(transcript): lossless transcript restore — full-fidelity cold-boot resume, per-turn thinking hydration, seq envelope (#5077)

This commit is contained in:
Steven Enamakel
2026-07-21 13:39:34 +03:00
committed by GitHub
parent 9420a2983a
commit 09f552ce40
23 changed files with 1699 additions and 353 deletions
@@ -46,6 +46,8 @@ import {
} from '../../features/conversations/components/ThreadGoalChip';
import { ThreadTodoStrip } from '../../features/conversations/components/ThreadTodoStrip';
import { ToolTimelineBlock } from '../../features/conversations/components/ToolTimelineBlock';
import { InterruptedAnswer } from '../../features/conversations/components/InterruptedAnswer';
import { PastTurnInsights } from '../../features/conversations/components/PastTurnInsights';
import {
evaluateComposerSend,
getComposerBlockedSendFeedback,
@@ -106,6 +108,7 @@ import {
hydrateThreadUsage,
markSubagentCancelled,
markThreadSendPending,
type ProcessingTranscriptItem,
type QueuedFollowup,
registerParallelRequest,
setTaskBoardForThread,
@@ -239,6 +242,16 @@ const EMPTY_QUEUED_FOLLOWUPS: Record<string, QueuedFollowup[]> = {};
// Stable empty reference for the per-thread past-turn timelines map, so the
// derived value keeps the same identity when the slice field is absent.
const EMPTY_TURN_TIMELINES: Record<string, ToolTimelineEntry[]> = {};
// Sibling stable empty for the per-thread past-turn processing transcripts map
// (restore-fidelity fix 1).
const EMPTY_TURN_TRANSCRIPTS: Record<string, ProcessingTranscriptItem[]> = {};
// Stable empty transcript for a past turn that has tool rows but no persisted
// reasoning/narration trail (legacy snapshot), so `PastTurnInsights` falls back
// to the tool-only view without allocating a fresh array each render.
const EMPTY_TRANSCRIPT: ProcessingTranscriptItem[] = [];
// Stable empty tool-row list for a transcript-only past turn (agent thought /
// narrated but ran no tools).
const EMPTY_TRANSCRIPT_ENTRIES: ToolTimelineEntry[] = [];
export function isComposerInteractionBlocked(args: {
/** Whether the *currently selected* thread has an in-flight inference turn. */
@@ -398,6 +411,12 @@ const Conversations = ({
const uiLocale = useAppSelector(state => state.locale?.current ?? 'en');
const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread);
const turnTimelinesByThread = useAppSelector(state => state.chatRuntime.turnTimelinesByThread);
const turnTranscriptsByThread = useAppSelector(
state => state.chatRuntime.turnTranscriptsByThread
);
const interruptedAssistantByThread = useAppSelector(
state => state.chatRuntime.interruptedAssistantByThread
);
const processingByThread = useAppSelector(state => state.chatRuntime.processingByThread);
const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread);
const inferenceStatusByThread = useAppSelector(
@@ -1834,21 +1853,33 @@ const Conversations = ({
const selectedThreadTurnTimelines = selectedThreadId
? (turnTimelinesByThread[selectedThreadId] ?? EMPTY_TURN_TIMELINES)
: EMPTY_TURN_TIMELINES;
// Sibling map: each past turn's persisted reasoning/narration trail, so a
// reopened turn replays its thoughts, not just its tool cards (fix 1).
const selectedThreadTurnTranscripts = selectedThreadId
? (turnTranscriptsByThread[selectedThreadId] ?? EMPTY_TURN_TRANSCRIPTS)
: EMPTY_TURN_TRANSCRIPTS;
const pastTurnAnchors = useMemo(() => {
const anchors: Record<string, ToolTimelineEntry[]> = {};
const anchors: Record<
string,
{ entries: ToolTimelineEntry[]; transcript: ProcessingTranscriptItem[] }
> = {};
const seen = new Set<string>();
for (const msg of timelineMessages) {
if (msg.sender !== 'agent') continue;
const requestId = msg.extraMetadata?.requestId;
if (typeof requestId !== 'string' || seen.has(requestId)) continue;
const entries = selectedThreadTurnTimelines[requestId];
if (entries && entries.length > 0) {
anchors[msg.id] = entries;
const entries = selectedThreadTurnTimelines[requestId] ?? EMPTY_TRANSCRIPT_ENTRIES;
const transcript = selectedThreadTurnTranscripts[requestId] ?? EMPTY_TRANSCRIPT;
// Anchor the turn when it has EITHER tool rows OR a reasoning/narration
// trail — a tool-less turn (agent only thought/narrated) must still
// render its restored thoughts above its answer (fix 1).
if (entries.length > 0 || transcript.length > 0) {
anchors[msg.id] = { entries, transcript };
seen.add(requestId);
}
}
return anchors;
}, [timelineMessages, selectedThreadTurnTimelines]);
}, [timelineMessages, selectedThreadTurnTimelines, selectedThreadTurnTranscripts]);
const activeSubagentTimelineEntry = selectedThreadToolTimeline.find(
entry => entry.status === 'running' && entry.name.startsWith('subagent:')
);
@@ -1861,6 +1892,12 @@ const Conversations = ({
const selectedStreamingAssistant = selectedThreadId
? (streamingAssistantByThread[selectedThreadId] ?? null)
: null;
// The partial reply an interrupted turn left behind (restore-fidelity fix 2):
// surfaced as a settled, marked-interrupted bubble on restore so a turn that
// crashed mid-answer keeps its visible work instead of rendering blank.
const selectedInterruptedAssistant = selectedThreadId
? (interruptedAssistantByThread[selectedThreadId] ?? null)
: null;
// Live streams for concurrent parallel (forked) turns on the selected thread,
// rendered as separate interleaved branch bubbles.
const selectedParallelStreams = selectedThreadId
@@ -1928,7 +1965,10 @@ const Conversations = ({
isSending ||
selectedThreadToolTimeline.length > 0 ||
selectedThreadProcessing.length > 0 ||
Boolean(selectedStreamingAssistant);
Boolean(selectedStreamingAssistant) ||
// An interrupted turn's restored partial answer must surface too, even
// before the durable message history loads (restore-fidelity fix 2).
Boolean(selectedInterruptedAssistant);
// Anchor the "Agentic task insights" panel right after the latest turn's user
// message — processing happens *before* the answer, so it reads above the
@@ -2259,14 +2299,20 @@ const Conversations = ({
// what keeps the marker text out of both the rendered bubble and
// the copy-to-clipboard action.
const parsedContent = parseMessageImages(msg.content ?? '');
const pastTurnEntries = pastTurnAnchors[msg.id];
const pastTurn = pastTurnAnchors[msg.id];
return (
<Fragment key={msg.id}>
{/* Past-turn process trail (Phase 5): each older settled turn's
tool timeline, collapsed, above the answer it produced. */}
{pastTurnEntries ? (
{/* Past-turn process trail (Phase 5 + restore-fidelity fix 1):
each older settled turn's interleaved reasoning/narration +
tool steps (and restored sub-agent transcripts), collapsed,
above the answer it produced. Falls back to tool-cards-only
for legacy snapshots with no persisted transcript. */}
{pastTurn ? (
<div data-testid="past-turn-insights">
<ToolTimelineBlock entries={pastTurnEntries} />
<PastTurnInsights
entries={pastTurn.entries}
transcript={pastTurn.transcript}
/>
</div>
) : null}
<div>
@@ -2661,6 +2707,16 @@ const Conversations = ({
</div>
</div>
)}
{/* Interrupted turn's partial answer (restore-fidelity fix 2):
a settled, marked-interrupted bubble surfaced on restore. Only
when NOT streaming live (the buffer is cleared by any live turn
in the slice; this guard is belt-and-braces). */}
{!isSending && selectedInterruptedAssistant ? (
<InterruptedAnswer
content={selectedInterruptedAssistant.content}
thinking={selectedInterruptedAssistant.thinking}
/>
) : null}
{/* Parallel (forked) branch streams — concurrent turns on this
thread, each its own labeled bubble so they don't collide with
the primary stream above. */}
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
import { store } from '../../../store';
import { InterruptedAnswer } from './InterruptedAnswer';
function renderInStore(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}
describe('InterruptedAnswer', () => {
it('surfaces the partial reply with an interrupted marker', () => {
renderInStore(
<InterruptedAnswer content="Here is the partial answer" thinking="was reasoning about it" />
);
const block = screen.getByTestId('interrupted-answer');
expect(block.textContent).toContain('Here is the partial answer');
// The reasoning it had streamed is kept in a collapsed block.
expect(block.textContent).toContain('was reasoning about it');
// Marked interrupted rather than presented as a finished answer.
expect(screen.getByTestId('interrupted-answer-marker')).toBeTruthy();
});
it('renders nothing when neither content nor thinking has any text', () => {
const { container } = renderInStore(<InterruptedAnswer content=" " thinking="" />);
expect(container.querySelector('[data-testid="interrupted-answer"]')).toBeNull();
});
});
@@ -0,0 +1,59 @@
import { useT } from '../../../lib/i18n/I18nContext';
import { BubbleMarkdown } from './AgentMessageBubble';
/**
* The partial assistant reply left behind by an INTERRUPTED turn — the core
* process that was streaming it exited before `chat_done`, so no final message
* was ever committed to the durable thread log (restore-fidelity fix 2).
*
* Unlike the live streaming preview, this is a SETTLED buffer: it renders as a
* static agent bubble (no pulsing cursor, full Markdown like a finished answer)
* carrying an "Interrupted" marker, plus the hidden reasoning it had streamed in
* a collapsed block. It is deliberately NOT written into the durable message
* list — it is a restore-time surfacing of what the agent had produced, so the
* user sees the partial work instead of a blank turn.
*/
export function InterruptedAnswer({
content,
thinking,
}: {
content: string;
thinking: string;
}) {
const { t } = useT();
const trimmedContent = content.trim();
const trimmedThinking = thinking.trim();
// Nothing persisted to show — render nothing rather than an empty marked bubble.
if (!trimmedContent && !trimmedThinking) return null;
return (
<div className="flex justify-start" data-testid="interrupted-answer">
<div className="relative w-fit max-w-[75%] space-y-1">
{trimmedThinking ? (
<details className="mb-0.5 rounded-lg bg-surface-subtle px-3 py-1.5 text-xs text-content-secondary dark:bg-surface-muted">
<summary className="flex cursor-pointer items-center gap-1.5 select-none">
<span aria-hidden className="text-[10px] leading-none">
💭
</span>
<span>{t('chat.thinking')}</span>
</summary>
<pre className="mt-1.5 font-sans text-[11px] break-words whitespace-pre-wrap text-content-muted">
{trimmedThinking}
</pre>
</details>
) : null}
<div className="rounded-2xl rounded-bl-md border-l-2 border-amber-400/70 bg-surface-strong/80 px-3 py-2 text-content dark:bg-surface-muted">
<div
className="mb-1 flex items-center gap-1.5 text-[10px] font-medium tracking-wide text-amber-600 uppercase dark:text-amber-300"
data-testid="interrupted-answer-marker">
<span aria-hidden className="text-[11px] leading-none">
</span>
<span>{t('intelligence.agentWork.status.interrupted')}</span>
</div>
{trimmedContent ? <BubbleMarkdown content={trimmedContent} /> : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
import { store } from '../../../store';
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { PastTurnInsights } from './PastTurnInsights';
function renderInStore(ui: React.ReactNode) {
return render(<Provider store={store}>{ui}</Provider>);
}
describe('PastTurnInsights', () => {
it('replays a restored turn as interleaved thoughts + tool rows, not just tool cards', () => {
// A reopened past turn carries both its reasoning trail and its tools.
const entries: ToolTimelineEntry[] = [
{ id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' },
];
const transcript: ProcessingTranscriptItem[] = [
{ kind: 'thinking', round: 0, seq: 1, text: 'planning the search' },
{ kind: 'toolCall', round: 0, seq: 2, callId: 'c1' },
];
renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />);
// The hidden reasoning replays (fix 1) — a restored turn is no longer
// tool-cards-only.
expect(screen.getByTestId('processing-thinking').textContent).toContain('planning the search');
// And its tool step still renders in the interleaved view.
expect(screen.getAllByTestId('processing-tool-row').length).toBeGreaterThan(0);
});
it('renders restored sub-agent transcripts beneath the trail', () => {
const entries: ToolTimelineEntry[] = [
{
id: 'subagent:task-y',
name: 'subagent:researcher',
round: 0,
seq: 0,
status: 'success',
subagent: {
taskId: 'task-y',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: 'child reasoning trail' }],
},
},
];
const transcript: ProcessingTranscriptItem[] = [
{ kind: 'narration', round: 0, seq: 0, text: 'delegating to a researcher' },
];
renderInStore(<PastTurnInsights entries={entries} transcript={transcript} />);
const subagents = screen.getByTestId('past-turn-subagents');
expect(subagents.textContent).toContain('child reasoning trail');
});
it('falls back to the tool-only timeline for a legacy turn with no transcript', () => {
const entries: ToolTimelineEntry[] = [
{ id: 'c1', name: 'read_file', round: 0, seq: 0, status: 'success' },
];
renderInStore(<PastTurnInsights entries={entries} transcript={[]} />);
// The interleaved transcript view is absent; the tool-only block renders.
expect(screen.queryByTestId('processing-transcript')).toBeNull();
expect(screen.getByTestId('agent-task-insights')).toBeTruthy();
});
});
@@ -0,0 +1,60 @@
import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice';
import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting';
import { ProcessingTranscriptView } from './ProcessingTranscriptView';
import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock';
/**
* The collapsed process trail rendered above a PAST (settled) turn's answer on a
* reopened thread — restore-fidelity fix 1.
*
* A restored turn must show the *same* interleaved thoughts + tools view a live
* turn does, not just its tool cards: the persisted `transcript`
* (narration/thinking/tool pointers, hydrated into
* `turnTranscriptsByThread`) drives {@link ProcessingTranscriptView}, so the
* reasoning and narration replay inline exactly where they streamed. Restored
* sub-agent transcripts (fix 4) render beneath as their own activity blocks,
* mirroring the whole-run {@link AgentProcessSourcePanel} body.
*
* Legacy turns persisted before the transcript field existed have no
* `transcript` — they fall back to the tool-only {@link ToolTimelineBlock}, so
* older threads keep rendering unchanged.
*/
export function PastTurnInsights({
entries,
transcript,
}: {
entries: ToolTimelineEntry[];
transcript: ProcessingTranscriptItem[];
}) {
// No reasoning/narration trail persisted (legacy snapshot): render the
// tool-only timeline, which already nests each sub-agent's activity inline.
if (transcript.length === 0) {
return <ToolTimelineBlock entries={entries} />;
}
const subagentEntries = entries.filter(entry => entry.subagent);
return (
<div className="space-y-3">
{/* Interleaved narration + hidden reasoning + grouped tool steps. */}
<ProcessingTranscriptView transcript={transcript} entries={entries} />
{/* Sub-agents — each delegated agent's restored transcript (thoughts +
tool rows), so a reopened turn keeps its sub-agent reasoning, not just
a flat tool row. `ProcessingTranscriptView` shows the spawn as a tool
row but does not nest the child's activity, so surface it here. */}
{subagentEntries.length > 0 ? (
<div className="space-y-3" data-testid="past-turn-subagents">
{subagentEntries.map(entry => (
<div key={entry.id}>
<p className="text-[12px] font-medium text-content-secondary">
{formatTimelineEntry(entry).title}
</p>
<SubagentActivityBlock subagent={entry.subagent!} />
</div>
))}
</div>
) : null}
</div>
);
}
@@ -85,10 +85,6 @@ export function ConversationTimeline({
/>
);
break;
case 'reasoning':
// Reasoning is rendered inside the process/streaming affordances today;
// no standalone element yet.
break;
default:
break;
}
@@ -50,7 +50,6 @@ export type TimelineItem = TimelineItemBase &
/** True for a `parallelStreamsByThread` forked branch. */
branch: boolean;
}
| { kind: 'reasoning'; text: string; settled: boolean }
| {
kind: 'toolCall';
/** The underlying runtime row (rendered via `ToolTimelineBlock`). */
@@ -75,11 +74,7 @@ export type TimelineItem = TimelineItemBase &
export type TimelineItemKind = TimelineItem['kind'];
/** The "agent process" kinds that `hideAgentInsights` suppresses. */
export const AGENT_INSIGHT_KINDS: readonly TimelineItemKind[] = [
'toolCall',
'subagentActivity',
'reasoning',
];
export const AGENT_INSIGHT_KINDS: readonly TimelineItemKind[] = ['toolCall', 'subagentActivity'];
/** A contiguous group of items sharing a `turnId`, in render order. */
export interface TimelineTurn {
+20
View File
@@ -17,6 +17,8 @@ const chatLog = debug('realtime:chat');
export interface ChatToolCallEvent {
thread_id: string;
request_id?: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
tool_name: string;
skill_id: string;
args: Record<string, unknown>;
@@ -42,6 +44,8 @@ export interface ChatToolCallEvent {
export interface ChatToolResultEvent {
thread_id: string;
request_id?: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
tool_name: string;
skill_id: string;
output: string;
@@ -85,6 +89,8 @@ export interface TurnUsageWire {
export interface ChatDoneEvent {
thread_id: string;
request_id?: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
full_response: string;
rounds_used: number;
/**
@@ -126,6 +132,8 @@ export interface ChatSegmentEvent {
*/
full_response: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
segment_index: number;
segment_total: number;
reaction_emoji?: string | null;
@@ -147,6 +155,8 @@ export function segmentText(event: ChatSegmentEvent): string {
export interface ChatInterimEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
/** Wire name is `full_response`; carries only this round's narration text. */
full_response: string;
round: number;
@@ -463,6 +473,8 @@ export interface ChatSubagentToolResultEvent {
export interface ChatSubagentTextDeltaEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
/** Parent iteration index (inherited from the parent context). */
round: number;
/** Text fragment from the sub-agent. */
@@ -478,6 +490,8 @@ export interface ChatSubagentTextDeltaEvent {
export interface ChatSubagentThinkingDeltaEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
round: number;
delta: string;
subagent?: SubagentProgressDetail;
@@ -491,6 +505,8 @@ export interface ChatSubagentThinkingDeltaEvent {
export interface ChatTextDeltaEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
/** 1-based iteration index the chunk belongs to. */
round: number;
/** Text fragment; may be a single token or a few characters. */
@@ -506,6 +522,8 @@ export interface ChatTextDeltaEvent {
export interface ChatThinkingDeltaEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
round: number;
delta: string;
}
@@ -519,6 +537,8 @@ export interface ChatThinkingDeltaEvent {
export interface ChatToolArgsDeltaEvent {
thread_id: string;
request_id: string;
/** Per-request monotonic ordering key stamped by the core progress bridge. */
seq?: number;
round: number;
tool_call_id: string;
tool_name: string;
@@ -47,7 +47,8 @@ describe('fetchAndHydrateTurnHistory', () => {
requestId: string,
lifecycle: string,
startedAt: string,
tools: number
tools: number,
transcript?: Array<Record<string, unknown>>
) => ({
threadId: 'thread-hist',
requestId,
@@ -62,6 +63,7 @@ describe('fetchAndHydrateTurnHistory', () => {
round: 0,
status: 'success' as const,
})),
...(transcript ? { transcript } : {}),
startedAt,
updatedAt: startedAt,
});
@@ -87,10 +89,43 @@ describe('fetchAndHydrateTurnHistory', () => {
expect(timelines['req-0']).toBeUndefined();
});
it('keeps each past turn\'s reasoning/narration transcript (fix 1), ordered by seq (fix 5)', async () => {
const store = configureStore({ reducer });
mockThreadApi.getTurnStateHistory.mockResolvedValueOnce([
// req-latest is skipped (index 0).
persistedTurn('req-latest', 'completed', '2026-06-04T13:00:00Z', 1),
// req-a: has tools AND a transcript, deliberately out of seq order.
persistedTurn('req-a', 'completed', '2026-06-04T12:00:00Z', 1, [
{ kind: 'narration', round: 0, seq: 1, text: 'second' },
{ kind: 'thinking', round: 0, seq: 0, text: 'first' },
]),
// req-b: a tool-LESS turn that only thought/narrated — must still be kept
// for its transcript rather than dropped.
persistedTurn('req-b', 'completed', '2026-06-04T11:00:00Z', 0, [
{ kind: 'thinking', round: 0, seq: 0, text: 'only thinking, no tools' },
]),
]);
await store.dispatch(fetchAndHydrateTurnHistory('thread-hist'));
const transcripts = store.getState().turnTranscriptsByThread['thread-hist'];
expect(Object.keys(transcripts).sort()).toEqual(['req-a', 'req-b']);
// Ordered by persisted `seq`, not wire order.
expect(transcripts['req-a'].map(i => ('text' in i ? i.text : undefined))).toEqual([
'first',
'second',
]);
// The tool-less turn is retained purely for its transcript.
const timelines = store.getState().turnTimelinesByThread['thread-hist'];
expect(timelines['req-b']).toBeUndefined();
expect(transcripts['req-b']).toHaveLength(1);
});
it('swallows transport failures without throwing', async () => {
const store = configureStore({ reducer });
mockThreadApi.getTurnStateHistory.mockRejectedValueOnce(new Error('boom'));
await expect(store.dispatch(fetchAndHydrateTurnHistory('t'))).resolves.toBeDefined();
expect(store.getState().turnTimelinesByThread['t']).toBeUndefined();
expect(store.getState().turnTranscriptsByThread['t']).toBeUndefined();
});
});
+147
View File
@@ -909,3 +909,150 @@ describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => {
expect(timeline[1].subagent?.toolCalls[0]?.result).toBe('file body');
});
});
describe('hydrateRuntimeFromSnapshot — interrupted partial answer (fix 2)', () => {
function makeInterruptedPartialSnapshot(
threadId: string,
over: Partial<PersistedTurnState> = {}
): PersistedTurnState {
return {
threadId,
requestId: 'req-int',
lifecycle: 'interrupted',
iteration: 2,
maxIterations: 10,
streamingText: 'Here is the partial ans',
thinking: 'was still reasoning',
toolTimeline: [],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
...over,
};
}
it('surfaces the persisted partial reply + thinking as a settled buffer', () => {
const store = makeStore();
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-int') }));
const state = store.getState().chatRuntime;
expect(state.interruptedAssistantByThread['t-int']).toEqual({
requestId: 'req-int',
content: 'Here is the partial ans',
thinking: 'was still reasoning',
});
// It is NOT resurrected as a live streaming buffer (would pulse).
expect(state.streamingAssistantByThread['t-int']).toBeUndefined();
// The lifecycle is recorded as interrupted, not a fake in-flight status.
expect(state.inferenceTurnLifecycleByThread['t-int']).toBe('interrupted');
});
it('keeps an interrupted turn that only produced thinking', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedPartialSnapshot('t-think', { streamingText: '' }),
})
);
expect(store.getState().chatRuntime.interruptedAssistantByThread['t-think']).toMatchObject({
content: '',
thinking: 'was still reasoning',
});
});
it('does not surface a partial for an interrupted turn with no persisted text', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedPartialSnapshot('t-empty', { streamingText: '', thinking: '' }),
})
);
expect(store.getState().chatRuntime.interruptedAssistantByThread['t-empty']).toBeUndefined();
});
it('clears a stale interrupted partial when a completed snapshot lands', () => {
const store = makeStore();
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeInterruptedPartialSnapshot('t-c') }));
expect(store.getState().chatRuntime.interruptedAssistantByThread['t-c']).toBeDefined();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedPartialSnapshot('t-c', {
lifecycle: 'completed',
streamingText: '',
thinking: '',
}),
})
);
expect(store.getState().chatRuntime.interruptedAssistantByThread['t-c']).toBeUndefined();
});
});
describe('hydrateRuntimeFromSnapshot — transcript seq ordering (fix 5)', () => {
it('orders the processing transcript by persisted seq, not array order', () => {
const store = makeStore();
const snapshot: PersistedTurnState = {
threadId: 't-seq',
requestId: 'req-1',
lifecycle: 'completed',
iteration: 1,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [],
// Deliberately out of order on the wire.
transcript: [
{ kind: 'narration', round: 0, seq: 2, text: 'second' },
{ kind: 'thinking', round: 0, seq: 0, text: 'first' },
{ kind: 'narration', round: 0, seq: 1, text: 'middle' },
],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot }));
const items = store.getState().chatRuntime.processingByThread['t-seq'];
expect(items.map(i => ('text' in i ? i.text : i.kind))).toEqual(['first', 'middle', 'second']);
});
});
describe('hydrateRuntimeFromSnapshot — sub-agent transcript fallback (fix 4)', () => {
it('rebuilds a tool-only transcript when no persisted prose field is present', () => {
const store = makeStore();
const snapshot: PersistedTurnState = {
threadId: 't-fallback',
requestId: 'req-1',
lifecycle: 'completed',
iteration: 1,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [
{
id: 'subagent:task-old',
name: 'subagent:researcher',
round: 1,
status: 'success',
subagent: {
taskId: 'task-old',
agentId: 'researcher',
// No `transcript` field (old snapshot) — only tool calls.
toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }],
},
},
],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot }));
const row = store
.getState()
.chatRuntime.toolTimelineByThread['t-fallback'].find(e => e.subagent?.taskId === 'task-old');
const transcript = row?.subagent?.transcript ?? [];
// Falls back to tool-only items so an old snapshot still shows the sequence.
expect(transcript).toHaveLength(1);
expect(transcript[0].kind).toBe('tool');
});
});
+123 -11
View File
@@ -647,6 +647,31 @@ interface ChatRuntimeState {
* rows live in `toolTimelineByThread` and are driven by the socket stream).
*/
turnTimelinesByThread: Record<string, Record<string, ToolTimelineEntry[]>>;
/**
* Per-turn processing transcripts (narration / thinking / tool pointers) for
* *past* (settled) turns of a thread, keyed `threadId -> requestId -> items`.
* Sibling of {@link turnTimelinesByThread}: that map holds the past turn's
* tool rows, this one its interleaved reasoning/narration trail so a reopened
* thread replays each past answer's thoughts — not just its tool cards
* (restore-fidelity fix 1). Hydrated from `turn_state_history`; the live turn
* is excluded (its transcript lives in {@link processingByThread}). Absent for
* legacy snapshots written before the transcript field existed.
*/
turnTranscriptsByThread: Record<string, Record<string, ProcessingTranscriptItem[]>>;
/**
* The partial assistant answer left behind by an INTERRUPTED turn (the core
* process that was streaming it is gone), keyed by thread. Surfaced on restore
* so a turn that crashed mid-answer keeps its visible partial reply + hidden
* reasoning instead of dropping them (restore-fidelity fix 2). Unlike
* {@link streamingAssistantByThread} this is a SETTLED, non-live buffer: it is
* rendered statically (no pulsing cursor) and marked interrupted. Populated
* only when an interrupted snapshot carries `streamingText`/`thinking`;
* cleared on any live turn, a completed snapshot, or a thread reset.
*/
interruptedAssistantByThread: Record<
string,
{ requestId: string; content: string; thinking: string }
>;
/**
* Ordered narration/thinking/tool transcript per thread for the
* "View processing" panel — the interleaved Hermes-style record. Hydrated
@@ -733,6 +758,8 @@ const initialState: ChatRuntimeState = {
toolTimelineByThread: {},
toolTimelineSeqByThread: {},
turnTimelinesByThread: {},
turnTranscriptsByThread: {},
interruptedAssistantByThread: {},
processingByThread: {},
taskBoardByThread: {},
inferenceTurnLifecycleByThread: {},
@@ -868,6 +895,26 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub
};
}
/**
* Order a persisted processing transcript by its per-item `seq` when every item
* carries one, falling back to the array (arrival) order otherwise
* (restore-fidelity fix 5: prefer `seq` for replay ordering when present). The
* core already writes items in `seq` order, so this is a defensive stable sort
* that also tolerates a snapshot whose items were reordered in transit. A stable
* sort preserves arrival order for any items that happen to share a `seq`.
*/
function orderTranscriptBySeq(items: ProcessingTranscriptItem[]): ProcessingTranscriptItem[] {
if (items.length < 2) return items;
const allHaveSeq = items.every(item => typeof item.seq === 'number');
if (!allHaveSeq) return items;
// `.sort` is stable in modern engines; map to (item, index) to make the
// tie-break on equal `seq` explicit rather than engine-dependent.
return items
.map((item, index) => ({ item, index }))
.sort((a, b) => a.item.seq - b.item.seq || a.index - b.index)
.map(({ item }) => item);
}
/**
* `seq` defaults to the array index the caller maps over — persisted
* `toolTimeline` order IS issue order (the core appends rows as it issues
@@ -1085,9 +1132,22 @@ const chatRuntimeSlice = createSlice({
*/
setTurnTimelinesForThread: (
state,
action: PayloadAction<{ threadId: string; timelines: Record<string, ToolTimelineEntry[]> }>
action: PayloadAction<{
threadId: string;
timelines: Record<string, ToolTimelineEntry[]>;
transcripts?: Record<string, ProcessingTranscriptItem[]>;
}>
) => {
state.turnTimelinesByThread[action.payload.threadId] = action.payload.timelines;
const { threadId, timelines, transcripts } = action.payload;
state.turnTimelinesByThread[threadId] = timelines;
if (transcripts) {
state.turnTranscriptsByThread[threadId] = transcripts;
turnStateLog(
'past-turn transcripts set thread=%s turns=%d',
threadId,
Object.keys(transcripts).length
);
}
},
/** Reset the live processing transcript at the start of a fresh turn so a
* new turn's narration/steps don't append onto the previous turn's. */
@@ -1864,6 +1924,7 @@ const chatRuntimeSlice = createSlice({
clearRuntimeForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.inferenceStatusByThread[action.payload.threadId];
delete state.streamingAssistantByThread[action.payload.threadId];
delete state.interruptedAssistantByThread[action.payload.threadId];
delete state.inferenceHeartbeatByThread[action.payload.threadId];
// Drop any parallel (forked) streams for this thread and their
// request→thread mappings — a hard per-thread reset covers every branch.
@@ -1900,6 +1961,8 @@ const chatRuntimeSlice = createSlice({
state.toolTimelineByThread = {};
state.toolTimelineSeqByThread = {};
state.turnTimelinesByThread = {};
state.turnTranscriptsByThread = {};
state.interruptedAssistantByThread = {};
state.processingByThread = {};
state.taskBoardByThread = {};
state.inferenceTurnLifecycleByThread = {};
@@ -2009,6 +2072,9 @@ const chatRuntimeSlice = createSlice({
if (snapshot.taskBoard) {
state.taskBoardByThread[threadId] = snapshot.taskBoard;
}
// A live turn is driving the thread — any interrupted partial from a
// prior crashed turn is superseded and must not linger under it.
delete state.interruptedAssistantByThread[threadId];
return;
}
@@ -2077,7 +2143,32 @@ const chatRuntimeSlice = createSlice({
// up rather than restarting at 0 and colliding with existing seqs.
state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length;
}
state.processingByThread[threadId] = snapshot.transcript ?? [];
// An interrupted turn was killed mid-answer (its core process is gone,
// so no `chat_done` will ever complete it). The partial reply +
// reasoning it had already streamed are persisted — surface them as a
// SETTLED buffer (rendered static + marked interrupted, not as a live
// pulsing stream) instead of dropping them (restore-fidelity fix 2). A
// `completed` turn's answer is the durable message, so it has no partial
// to keep — clear any stale interrupted buffer for the thread instead.
if (
snapshot.lifecycle === 'interrupted' &&
(snapshot.streamingText.length > 0 || snapshot.thinking.length > 0)
) {
state.interruptedAssistantByThread[threadId] = {
requestId: snapshot.requestId,
content: snapshot.streamingText,
thinking: snapshot.thinking,
};
turnStateLog(
'interrupted partial kept thread=%s chars=%d thinkingChars=%d',
threadId,
snapshot.streamingText.length,
snapshot.thinking.length
);
} else {
delete state.interruptedAssistantByThread[threadId];
}
state.processingByThread[threadId] = orderTranscriptBySeq(snapshot.transcript ?? []);
return;
}
@@ -2102,6 +2193,9 @@ const chatRuntimeSlice = createSlice({
} else {
delete state.streamingAssistantByThread[threadId];
}
// This snapshot is in-flight (a live driver may be resuming it), not a
// settled interruption — drop any stale interrupted partial for the thread.
delete state.interruptedAssistantByThread[threadId];
state.toolTimelineByThread[threadId] = preserveLiveSubagentProse(
state.toolTimelineByThread[threadId],
@@ -2110,7 +2204,7 @@ const chatRuntimeSlice = createSlice({
// Persisted order is issue order — seed the live counter with the row
// count so events arriving after this hydration keep counting up.
state.toolTimelineSeqByThread[threadId] = snapshot.toolTimeline.length;
state.processingByThread[threadId] = snapshot.transcript ?? [];
state.processingByThread[threadId] = orderTranscriptBySeq(snapshot.transcript ?? []);
},
/**
* Rebuild durable historical subagent rows from the run ledger. This is
@@ -2266,23 +2360,41 @@ export const fetchAndHydrateTurnHistory = createAsyncThunk(
try {
const history = await threadApi.getTurnStateHistory(threadId);
const timelines: Record<string, ToolTimelineEntry[]> = {};
const transcripts: Record<string, ProcessingTranscriptItem[]> = {};
// History is newest-first; the newest turn is the one `getTurnState`
// hydrates into `toolTimelineByThread` (rendered as the live/anchored
// "agent insights"), so skip it here to avoid rendering it twice — this
// field holds only the *older* settled turns.
for (const turn of history.slice(1)) {
if (turn.lifecycle !== 'completed' && turn.lifecycle !== 'interrupted') continue;
if (!turn.requestId || turn.toolTimeline.length === 0) continue;
timelines[turn.requestId] = turn.toolTimeline.map((e, seq) =>
toolTimelineFromPersisted(e, seq)
);
if (!turn.requestId) continue;
// A past turn can have a reasoning/narration trail with NO tool calls
// (the agent only thought/narrated). Keep the turn whenever it has
// either a tool timeline OR a transcript so a tool-less answer still
// replays its thoughts (restore-fidelity fix 1) — the old
// `toolTimeline.length === 0` skip dropped those turns entirely.
const hasTools = turn.toolTimeline.length > 0;
const persistedTranscript = turn.transcript ?? [];
const hasTranscript = persistedTranscript.length > 0;
if (!hasTools && !hasTranscript) continue;
if (hasTools) {
timelines[turn.requestId] = turn.toolTimeline.map((e, seq) =>
toolTimelineFromPersisted(e, seq)
);
}
if (hasTranscript) {
// Prefer persisted `seq` for replay order, falling back to array
// order (restore-fidelity fix 5).
transcripts[turn.requestId] = orderTranscriptBySeq(persistedTranscript);
}
}
turnStateLog(
'hydrated turn history thread=%s turns=%d',
'hydrated turn history thread=%s timelines=%d transcripts=%d',
threadId,
Object.keys(timelines).length
Object.keys(timelines).length,
Object.keys(transcripts).length
);
dispatch(setTurnTimelinesForThread({ threadId, timelines }));
dispatch(setTurnTimelinesForThread({ threadId, timelines, transcripts }));
return timelines;
} catch (error) {
turnStateLog('history fetch failed thread=%s err=%O', threadId, error);
+5
View File
@@ -156,6 +156,11 @@ export interface PersistedToolTimelineEntry {
/** Size-capped tool result text. Absent while running and on snapshots
* written before this field. */
output?: string;
/** Per-turn monotonic ordering key stamped when the row is first created, so
* a rehydrated timeline orders rows identically to the live stream (shares
* the per-turn ordering space with {@link PersistedTranscriptItem.seq}).
* Absent on snapshots written before this field. */
seq?: number;
}
export interface PersistedTurnState {
+9
View File
@@ -223,6 +223,15 @@ pub struct WebChannelEvent {
/// for synthetic done events that never ran a real turn.
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TurnUsagePayload>,
/// Additive per-request monotonic ordering key stamped by the web-channel
/// progress bridge on every event it emits (conversations-timeline-refactor,
/// Phase 4). Together with the always-present `request_id`, the frontend
/// dedups replayed vs live events by `(request_id, seq)` and orders them
/// identically to the persisted turn-state snapshot. `None` on events not
/// emitted through the stamping bridge and on older cores — older frontends
/// simply ignore it.
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}
/// Token/cost/context totals for one completed turn, attached to `chat_done`.
@@ -386,6 +386,105 @@ impl Agent {
Ok(())
}
/// Cold-boot resume for the web-chat path: pre-populate this session's
/// LLM context from the **full-fidelity** `session_raw/{stem}.jsonl`
/// transcript for `thread_id`.
///
/// This is the high-fidelity counterpart to
/// [`Self::seed_resume_from_messages`]. That fallback sources lossy
/// `(sender, content)` prose from the conversation log, so it drops every
/// tool call, tool-role result, and reasoning block — after an app restart
/// the model then "forgets" all its tool interactions. This path instead
/// routes thread → transcript via
/// [`transcript::find_root_transcript_for_thread`] and reuses the exact
/// [`transcript::read_transcript`] +
/// [`Self::bound_cached_transcript_messages`] machinery as
/// [`Self::try_load_session_transcript`], so `tool_calls`, `role:"tool"`
/// messages, and `reasoning_content` all survive the round-trip. The only
/// difference from `try_load_session_transcript` is the lookup key (thread
/// id vs. per-thread agent name), so a thread whose transcript was written
/// under a differently-scoped agent name still resumes.
///
/// Returns `true` when a transcript was found, loaded, and seeded into
/// `cached_transcript_messages`; `false` (a no-op) when the agent is already
/// warm, no root transcript exists for the thread, the transcript is empty,
/// or it fails to parse — the caller then falls back to prose-pair seeding.
///
/// Best-effort like `try_load_session_transcript`: read/parse failures are
/// logged and reported as `false` rather than propagated. The current turn's
/// user message is appended later by [`Self::run_single`] / `turn`, so it is
/// intentionally absent from the loaded prefix — no dedup is needed here (the
/// on-disk transcript ends at the previous completed turn).
pub fn seed_resume_from_thread_transcript(&mut self, thread_id: &str) -> bool {
if !self.history.is_empty() || self.cached_transcript_messages.is_some() {
log::debug!(
"[web-channel] seed_resume_from_thread_transcript no-op — agent already warm \
(history_len={}, cached={}) thread={thread_id}",
self.history.len(),
self.cached_transcript_messages.is_some()
);
return false;
}
let Some(path) =
super::transcript::find_root_transcript_for_thread(&self.workspace_dir, thread_id)
else {
log::debug!(
"[web-channel] no root session_raw transcript for thread={thread_id} — \
falling back to conversation-log prose seeding"
);
return false;
};
log::info!(
"[web-channel] cold-boot resume — loading full-fidelity transcript for \
thread={thread_id} path={}",
path.display()
);
match super::transcript::read_transcript(&path) {
Ok(session) => {
if session.messages.is_empty() {
log::debug!(
"[web-channel] root transcript for thread={thread_id} is empty — \
falling back to prose seeding"
);
return false;
}
let loaded_count = session.messages.len();
// Count the tool-role results carried into the resumed prefix —
// the fidelity the prose fallback would have silently dropped.
let tool_result_msgs = session.messages.iter().filter(|m| m.role == "tool").count();
let bounded = self.bound_cached_transcript_messages(session.messages);
if bounded.len() < loaded_count {
log::warn!(
"[web-channel] resume prefix trimmed from {} to {} messages \
(max_history_messages={}) for thread={thread_id}",
loaded_count,
bounded.len(),
self.config.max_history_messages
);
}
log::info!(
"[web-channel] cold-boot resume — primed {} transcript message(s) \
({} tool-role result(s) preserved) for thread={thread_id}",
bounded.len(),
tool_result_msgs
);
self.cached_transcript_messages = Some(bounded);
true
}
Err(err) => {
log::warn!(
"[web-channel] failed to parse root transcript {} for thread={thread_id}: \
{err} — falling back to prose seeding",
path.display()
);
false
}
}
}
/// Drain and return memory citations collected for the latest completed turn.
pub fn take_last_turn_citations(
&mut self,
@@ -1342,6 +1342,171 @@ fn bound_cached_transcript_messages_snaps_past_leading_orphan_tool() {
);
}
/// Cold-boot web-chat resume must prefer the full-fidelity `session_raw`
/// transcript over lossy conversation-log prose. This is the regression for the
/// "model forgets its tool interactions after an app restart" bug: once the
/// in-memory agent is dropped and a fresh agent cold-boots for the same thread,
/// the resumed context must still carry the tool call, the tool-role result, and
/// the reasoning that prose seeding (`seed_resume_from_messages`) discards.
#[test]
fn seed_resume_from_thread_transcript_preserves_tool_calls_and_reasoning() {
use super::transcript::{self, MessageUsage, TranscriptMeta, TurnUsage};
use crate::openhuman::inference::provider::{ChatMessage, ToolCall};
let ws = tempfile::TempDir::new().expect("temp workspace");
let wsp = ws.path().to_path_buf();
let thread_id = "thr_resume_fidelity";
// ── Simulate a prior session persisted to session_raw carrying a tool
// call + reasoning on the tool-calling assistant turn and a tool-role
// result — exactly the fidelity the prose fallback drops. ──
let mut assistant_toolcall = ChatMessage::assistant("Let me look that up.");
transcript::attach_turn_usage_metadata(
&mut assistant_toolcall,
&TurnUsage {
provider: "openai".to_string(),
model: "gpt-x".to_string(),
usage: MessageUsage {
input: 10,
output: 5,
cached_input: 0,
context_window: 0,
cost_usd: 0.0,
},
ts: "2026-01-01T00:00:00Z".to_string(),
reasoning_content: Some("I should search the web for the price.".to_string()),
tool_calls: vec![ToolCall {
id: "call_1".to_string(),
name: "web_search".to_string(),
arguments: r#"{"query":"btc price"}"#.to_string(),
extra_content: None,
}],
iteration: 1,
},
);
let messages = vec![
ChatMessage::system("system prompt"),
ChatMessage::user("what is btc price"),
assistant_toolcall,
ChatMessage::tool(r#"{"tool_call_id":"call_1","content":"$80,000"}"#),
ChatMessage::assistant("BTC is around $80,000."),
];
let meta = TranscriptMeta {
agent_name: "orchestrator_thread-resume".to_string(),
agent_id: Some("orchestrator".to_string()),
agent_type: Some("root".to_string()),
dispatcher: "native".to_string(),
provider: None,
model: None,
created: "2026-01-01T00:00:00Z".to_string(),
updated: "2026-01-01T00:00:00Z".to_string(),
turn_count: 1,
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 0,
charged_amount_usd: 0.0,
thread_id: Some(thread_id.to_string()),
task_id: None,
};
// Root stem: no `__`, so `find_root_transcript_for_thread` accepts it.
let path = transcript::resolve_keyed_transcript_path(&wsp, "1700000000_orchestrator")
.expect("resolve transcript path");
transcript::write_transcript(&path, &messages, &meta, None).expect("write transcript");
// ── Cold boot: a brand-new agent for the same thread whose agent
// definition name deliberately does NOT match the transcript stem — the
// resume must route purely by thread id, not by agent name. ──
let memory_cfg = crate::openhuman::config::MemoryConfig {
backend: "none".into(),
..crate::openhuman::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::openhuman::memory_store::create_memory(&memory_cfg, &wsp).unwrap());
let mut agent = Agent::builder()
.provider(Box::new(MockProvider {
responses: Mutex::new(vec![]),
}))
.tools(vec![Box::new(MockTool)])
.memory(mem)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.agent_definition_name("some_other_agent_name")
.workspace_dir(wsp.clone())
.build()
.expect("agent build should succeed");
let loaded = agent.seed_resume_from_thread_transcript(thread_id);
assert!(
loaded,
"cold-boot resume must load the thread's root transcript"
);
let cached = agent
.cached_transcript_messages
.as_ref()
.expect("cached transcript populated");
// The tool-role result must survive — prose seeding would have dropped it.
assert!(
cached.iter().any(|m| m.role == "tool"),
"resumed context must include the tool-role result message"
);
// The assistant tool call + reasoning survive, carried in metadata.
let tool_call_carrier = cached
.iter()
.find(|m| {
m.role == "assistant"
&& m.extra_metadata
.as_ref()
.and_then(|v| v.get("openhuman_turn_usage"))
.is_some()
})
.expect("resumed context must include the assistant tool-call turn");
let usage_value = tool_call_carrier
.extra_metadata
.as_ref()
.and_then(|v| v.get("openhuman_turn_usage"))
.cloned()
.expect("turn usage metadata present");
let parsed: TurnUsage = serde_json::from_value(usage_value).expect("turn usage deserializes");
assert!(
parsed.tool_calls.iter().any(|c| c.name == "web_search"),
"the persisted tool call must round-trip into the resumed context"
);
assert_eq!(
parsed.reasoning_content.as_deref(),
Some("I should search the web for the price."),
"reasoning content must be preserved on resume"
);
}
/// When no root transcript exists for the thread, the transcript resume is a
/// no-op returning `false` so the caller falls back to prose-pair seeding.
#[test]
fn seed_resume_from_thread_transcript_returns_false_without_transcript() {
let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator"));
assert!(!agent.seed_resume_from_thread_transcript("thr_missing"));
assert!(agent.cached_transcript_messages.is_none());
}
/// Transcript resume must not stomp an already-warm agent (in-process session
/// cache hit) — mirrors the `seed_resume_from_messages` warm-agent guard.
#[test]
fn seed_resume_from_thread_transcript_is_noop_on_warm_agent() {
let mut agent = build_minimal_agent_with_definition_name(Some("orchestrator"));
agent.cached_transcript_messages = Some(vec![
crate::openhuman::inference::provider::ChatMessage::system("warm prefix"),
]);
assert!(!agent.seed_resume_from_thread_transcript("thr_x"));
let cached = agent
.cached_transcript_messages
.as_ref()
.expect("still populated");
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].content, "warm prefix");
}
/// `hide_tools` on an agent that already has a visible-tool filter must drop
/// only the named tools and leave the rest of the belt intact.
#[test]
+3
View File
@@ -220,6 +220,9 @@ impl EventHandler for ProactiveMessageSubscriber {
tool_display_label: None,
tool_display_detail: None,
usage: None,
// Proactive delivery is emitted outside the seq-stamping progress
// bridge; leave `seq` unset (older clients ignore it).
seq: None,
});
// 2. If an active external channel is configured, deliver there too.
+78 -16
View File
@@ -36,6 +36,47 @@ const MAX_PERSISTED_TOOL_OUTPUT: usize = 64 * 1024;
/// [`MAX_PERSISTED_TOOL_OUTPUT`].
const TRUNCATION_MARKER_BUDGET: usize = 80;
/// Upper bound on a single persisted transcript prose item (one coalesced
/// narration or reasoning block, parent or sub-agent). A runaway reasoning
/// stream would otherwise grow one item without bound and bloat every
/// full-file snapshot rewrite. Tighter than [`MAX_PERSISTED_TOOL_OUTPUT`]
/// because a turn can accumulate many prose items.
const MAX_PERSISTED_TRANSCRIPT_ITEM: usize = 16 * 1024;
/// Marker appended once when a transcript prose item is truncated at its cap.
const TRANSCRIPT_TRUNCATION_MARKER: &str = "\n…[truncated]";
/// Append `delta` to a coalescing transcript prose buffer, enforcing
/// [`MAX_PERSISTED_TRANSCRIPT_ITEM`] on a char boundary and stamping a one-time
/// truncation marker the first time the cap is hit. Once at the cap, further
/// deltas are dropped (the marker is already present). Used by both the parent
/// and sub-agent transcript coalescers so a single streamed block stays bounded.
fn append_capped_transcript_text(text: &mut String, delta: &str) {
if delta.is_empty() {
return;
}
if text.len() >= MAX_PERSISTED_TRANSCRIPT_ITEM {
// Already at the cap but more content is arriving — ensure the marker is
// present exactly once so the truncation is visible even when deltas
// land exactly on the boundary (never straddling it).
if !text.ends_with(TRANSCRIPT_TRUNCATION_MARKER) {
text.push_str(TRANSCRIPT_TRUNCATION_MARKER);
}
return;
}
let remaining = MAX_PERSISTED_TRANSCRIPT_ITEM - text.len();
if delta.len() <= remaining {
text.push_str(delta);
return;
}
let mut end = remaining;
while end > 0 && !delta.is_char_boundary(end) {
end -= 1;
}
text.push_str(&delta[..end]);
text.push_str(TRANSCRIPT_TRUNCATION_MARKER);
}
/// Cap `output` for snapshot persistence, slicing on a char boundary and
/// appending a truncation marker when content was dropped. Returns `None`
/// for empty output (payload capture off) so the field serializes away.
@@ -69,6 +110,11 @@ pub struct TurnStateMirror {
/// order narration vs thinking vs tool calls *within* one iteration, so
/// every transcript push stamps and increments this.
next_seq: u32,
/// Separate monotonic ordering key for [`ToolTimelineEntry::seq`] — the flat
/// timeline is an independent projection from the interleaved transcript, so
/// it gets its own space (sharing `next_seq` would leave gaps in the
/// transcript's contiguous ordering).
next_tool_seq: u64,
}
impl TurnStateMirror {
@@ -87,6 +133,7 @@ impl TurnStateMirror {
state,
turn_completed: false,
next_seq: 0,
next_tool_seq: 0,
};
mirror.flush();
mirror
@@ -153,6 +200,7 @@ impl TurnStateMirror {
existing.detail = display_detail.clone();
}
} else {
let seq = self.next_tool_seq();
self.state.tool_timeline.push(ToolTimelineEntry {
id: call_id.clone(),
name: tool_name.clone(),
@@ -165,6 +213,7 @@ impl TurnStateMirror {
subagent: None,
failure: None,
output: None,
seq: Some(seq),
});
}
self.flush();
@@ -216,6 +265,7 @@ impl TurnStateMirror {
} => {
self.state.phase = Some(TurnPhase::Subagent);
self.state.active_subagent = Some(agent_id.clone());
let seq = self.next_tool_seq();
self.state.tool_timeline.push(ToolTimelineEntry {
id: format!("subagent:{task_id}"),
name: format!("subagent:{agent_id}"),
@@ -242,6 +292,7 @@ impl TurnStateMirror {
}),
failure: None,
output: None,
seq: Some(seq),
});
self.flush();
true
@@ -447,6 +498,7 @@ impl TurnStateMirror {
// No matching entry yet — `ToolCallArgsDelta` may
// arrive before `ToolCallStarted` so synthesise a
// placeholder we can update once the start event lands.
let seq = self.next_tool_seq();
self.state.tool_timeline.push(ToolTimelineEntry {
id: call_id.clone(),
name: tool_name.clone(),
@@ -459,6 +511,7 @@ impl TurnStateMirror {
subagent: None,
failure: None,
output: None,
seq: Some(seq),
});
}
false
@@ -526,16 +579,16 @@ impl TurnStateMirror {
self.state.transcript.last_mut()
{
if *r == round {
text.push_str(delta);
append_capped_transcript_text(text, delta);
return;
}
}
let seq = self.next_seq();
self.state.transcript.push(TranscriptItem::Narration {
round,
seq,
text: delta.to_string(),
});
let mut text = String::new();
append_capped_transcript_text(&mut text, delta);
self.state
.transcript
.push(TranscriptItem::Narration { round, seq, text });
}
/// Append a hidden-reasoning delta to the transcript, with the same
@@ -545,16 +598,16 @@ impl TurnStateMirror {
self.state.transcript.last_mut()
{
if *r == round {
text.push_str(delta);
append_capped_transcript_text(text, delta);
return;
}
}
let seq = self.next_seq();
self.state.transcript.push(TranscriptItem::Thinking {
round,
seq,
text: delta.to_string(),
});
let mut text = String::new();
append_capped_transcript_text(&mut text, delta);
self.state
.transcript
.push(TranscriptItem::Thinking { round, seq, text });
}
/// Record a tool call in the transcript at the point it occurred, as a
@@ -583,6 +636,13 @@ impl TurnStateMirror {
seq
}
/// Return the next monotonic tool-timeline ordering key and advance it.
fn next_tool_seq(&mut self) -> u64 {
let seq = self.next_tool_seq;
self.next_tool_seq = self.next_tool_seq.saturating_add(1);
seq
}
fn find_subagent_entry_mut(&mut self, task_id: &str) -> Option<&mut ToolTimelineEntry> {
let needle = format!("subagent:{task_id}");
self.state
@@ -616,27 +676,29 @@ impl TurnStateMirror {
iteration: it,
text,
}) if is_thinking && *it == Some(iteration) => {
text.push_str(delta);
append_capped_transcript_text(text, delta);
return;
}
Some(SubagentTranscriptItem::Text {
iteration: it,
text,
}) if !is_thinking && *it == Some(iteration) => {
text.push_str(delta);
append_capped_transcript_text(text, delta);
return;
}
_ => {}
}
let mut text = String::new();
append_capped_transcript_text(&mut text, delta);
activity.transcript.push(if is_thinking {
SubagentTranscriptItem::Thinking {
iteration: Some(iteration),
text: delta.to_string(),
text,
}
} else {
SubagentTranscriptItem::Text {
iteration: Some(iteration),
text: delta.to_string(),
text,
}
});
}
@@ -194,6 +194,160 @@ fn tool_call_completed_persists_capped_output() {
assert!(persisted.contains("truncated"));
}
#[test]
fn tool_timeline_entries_carry_monotonic_seq() {
// Every timeline row is stamped with a per-turn monotonic `seq` at creation
// so a rehydrated snapshot can order rows identically to the live stream
// (conversations-timeline-refactor, Phase 4 amendment). Cover the three
// creation paths: a normal tool start, an args-delta placeholder, and a
// sub-agent spawn.
let (_d, mut m) = fresh("t");
m.observe(&AgentProgress::IterationStarted {
iteration: 1,
max_iterations: 25,
});
m.observe(&AgentProgress::ToolCallStarted {
call_id: "tc-1".into(),
tool_name: "shell".into(),
arguments: serde_json::json!({}),
iteration: 1,
display_label: None,
display_detail: None,
});
// Placeholder-first path (args delta before start) for a second call.
m.observe(&AgentProgress::ToolCallArgsDelta {
call_id: "tc-2".into(),
tool_name: "shell".into(),
delta: "{".into(),
iteration: 1,
});
m.observe(&AgentProgress::SubagentSpawned {
agent_id: "researcher".into(),
task_id: "sub-1".into(),
mode: "typed".into(),
dedicated_thread: false,
prompt_chars: 10,
prompt: String::new(),
worker_thread_id: None,
display_name: None,
});
let seqs: Vec<u64> = m
.snapshot()
.tool_timeline
.iter()
.map(|e| e.seq.expect("every row is seq-stamped"))
.collect();
assert_eq!(seqs.len(), 3);
// Strictly increasing in creation order.
assert!(
seqs.windows(2).all(|w| w[0] < w[1]),
"tool timeline seqs must be strictly increasing, got {seqs:?}"
);
// A later `ToolCallStarted` that reuses the args-delta placeholder must NOT
// restamp the row's seq (the row keeps its original creation order).
let tc2_seq_before = m
.snapshot()
.tool_timeline
.iter()
.find(|e| e.id == "tc-2")
.and_then(|e| e.seq)
.expect("placeholder seq");
m.observe(&AgentProgress::ToolCallStarted {
call_id: "tc-2".into(),
tool_name: "shell".into(),
arguments: serde_json::json!({}),
iteration: 1,
display_label: None,
display_detail: None,
});
let tc2_seq_after = m
.snapshot()
.tool_timeline
.iter()
.find(|e| e.id == "tc-2")
.and_then(|e| e.seq)
.expect("placeholder seq after reuse");
assert_eq!(
tc2_seq_before, tc2_seq_after,
"reusing a placeholder must not restamp its seq"
);
}
#[test]
fn subagent_prose_item_is_size_capped() {
// A runaway reasoning stream must not grow a single transcript item without
// bound (the snapshot is rewritten in full at every flush). Streaming far
// past the per-item cap coalesces into one item that stays bounded and
// carries a truncation marker.
let (_d, mut m) = fresh("t");
m.observe(&AgentProgress::IterationStarted {
iteration: 1,
max_iterations: 25,
});
m.observe(&AgentProgress::SubagentSpawned {
agent_id: "researcher".into(),
task_id: "sub-1".into(),
mode: "typed".into(),
dedicated_thread: false,
prompt_chars: 10,
prompt: String::new(),
worker_thread_id: None,
display_name: None,
});
// 40 KiB of reasoning in same-iteration chunks — must coalesce and cap.
for _ in 0..40 {
m.observe(&AgentProgress::SubagentThinkingDelta {
agent_id: "researcher".into(),
task_id: "sub-1".into(),
delta: "x".repeat(1024),
iteration: 1,
});
}
let activity = m.snapshot().tool_timeline[0]
.subagent
.as_ref()
.expect("activity")
.clone();
assert_eq!(
activity.transcript.len(),
1,
"same-iteration prose coalesces"
);
match &activity.transcript[0] {
SubagentTranscriptItem::Thinking { text, .. } => {
assert!(
text.len() <= super::MAX_PERSISTED_TRANSCRIPT_ITEM + 64,
"capped prose item stays bounded, got {} bytes",
text.len()
);
assert!(text.contains("truncated"), "capped item carries a marker");
}
other => panic!("expected thinking, got {other:?}"),
}
}
#[test]
fn parent_transcript_prose_item_is_size_capped() {
let (_d, mut m) = fresh("t");
for _ in 0..40 {
m.observe(&AgentProgress::ThinkingDelta {
delta: "y".repeat(1024),
iteration: 1,
});
}
let s = m.snapshot();
assert_eq!(s.transcript.len(), 1);
match &s.transcript[0] {
TranscriptItem::Thinking { text, .. } => {
assert!(text.len() <= super::MAX_PERSISTED_TRANSCRIPT_ITEM + 64);
assert!(text.contains("truncated"));
}
other => panic!("expected thinking, got {other:?}"),
}
}
#[test]
fn args_delta_arriving_before_start_creates_placeholder() {
let (_d, mut m) = fresh("t");
+104 -1
View File
@@ -2,7 +2,8 @@
use super::*;
use crate::openhuman::threads::turn_state::types::{
ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle, TurnState,
SubagentActivity, SubagentToolCall, SubagentTranscriptItem, ToolTimelineEntry,
ToolTimelineStatus, TurnLifecycle, TurnState,
};
use tempfile::tempdir;
@@ -44,6 +45,7 @@ fn put_then_get_roundtrips_state() {
subagent: None,
failure: None,
output: None,
seq: Some(0),
});
store.put(&state).expect("put");
@@ -51,6 +53,107 @@ fn put_then_get_roundtrips_state() {
assert_eq!(loaded, state);
}
#[test]
fn roundtrips_subagent_interleaved_transcript_with_full_fidelity() {
// A settled turn whose subagent streamed reasoning, called a tool, then
// narrated — the interleaved transcript (not just the flat tool rows) plus
// the per-row `seq` ordering keys must survive a disk round-trip verbatim,
// so a reopened transcript rehydrates without losing the subagent's
// reasoning (the gap SubagentDrawer/chatRuntimeSlice documented).
let dir = tempdir().expect("tempdir");
let store = TurnStateStore::new(dir.path().to_path_buf());
let mut state = sample_state("thread-sub");
state.lifecycle = TurnLifecycle::Completed;
let activity = SubagentActivity {
task_id: "sub-1".into(),
agent_id: "researcher".into(),
status: Some("completed".into()),
mode: Some("typed".into()),
dedicated_thread: Some(false),
child_iteration: Some(1),
child_max_iterations: Some(8),
iterations: Some(2),
elapsed_ms: Some(1234),
output_chars: Some(42),
worker_thread_id: Some("worker-thread-9".into()),
tool_calls: vec![SubagentToolCall {
call_id: "c1".into(),
tool_name: "search".into(),
status: ToolTimelineStatus::Success,
iteration: Some(1),
elapsed_ms: Some(12),
output_chars: Some(6),
display_name: Some("Searching".into()),
detail: None,
failure: None,
output: Some("3 hits".into()),
}],
transcript: vec![
SubagentTranscriptItem::Thinking {
iteration: Some(1),
text: "let me search.".into(),
},
SubagentTranscriptItem::Tool {
iteration: Some(1),
call_id: "c1".into(),
tool_name: "search".into(),
status: ToolTimelineStatus::Success,
elapsed_ms: Some(12),
output_chars: Some(6),
display_name: Some("Searching".into()),
detail: None,
},
SubagentTranscriptItem::Text {
iteration: Some(1),
text: "Found it.".into(),
},
],
};
state.tool_timeline.push(ToolTimelineEntry {
id: "subagent:sub-1".into(),
name: "subagent:researcher".into(),
round: 1,
status: ToolTimelineStatus::Success,
args_buffer: None,
display_name: Some("Researcher".into()),
detail: None,
source_tool_name: Some("spawn_subagent".into()),
subagent: Some(activity),
failure: None,
output: None,
seq: Some(3),
});
store.put(&state).expect("put");
let loaded = store.get("thread-sub").expect("get").expect("present");
// Structural equality proves nothing in the interleaved transcript,
// subagent activity, or the `seq` ordering keys was dropped or reordered.
assert_eq!(loaded, state);
// Spot-check the interleaving explicitly so a future regression that keeps
// the fields but loses the ordering still fails here.
let activity = loaded.tool_timeline[0]
.subagent
.as_ref()
.expect("subagent activity restored");
assert_eq!(activity.transcript.len(), 3);
assert!(matches!(
activity.transcript[0],
SubagentTranscriptItem::Thinking { .. }
));
assert!(matches!(
activity.transcript[1],
SubagentTranscriptItem::Tool { .. }
));
assert!(matches!(
activity.transcript[2],
SubagentTranscriptItem::Text { .. }
));
assert_eq!(loaded.tool_timeline[0].seq, Some(3));
}
#[test]
fn get_returns_none_when_absent() {
let dir = tempdir().expect("tempdir");
@@ -125,6 +125,13 @@ pub struct ToolTimelineEntry {
/// `tool_result`. `None` while running and on legacy snapshots.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
/// Per-turn monotonic ordering key stamped at the moment the row is first
/// created, so a rehydrated timeline can order rows identically to the live
/// stream (conversations-timeline-refactor, Phase 4 amendment). Shares the
/// per-turn ordering space with [`TranscriptItem::seq`]. `None` on snapshots
/// written before this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}
/// Live sub-agent activity nested under a `subagent:*` timeline row.
+7
View File
@@ -110,6 +110,9 @@ pub(crate) async fn deliver_response(
Some(serde_json::json!(citations))
},
usage: usage_payload,
// Terminal delivery events are emitted outside the seq-stamping
// progress bridge; leave `seq` unset (older clients ignore it).
seq: None,
});
return;
}
@@ -161,6 +164,7 @@ pub(crate) async fn deliver_response(
},
// Usage is attached only to the terminal `chat_done`, never segments.
usage: None,
seq: None,
});
}
@@ -201,6 +205,9 @@ pub(crate) async fn deliver_response(
Some(serde_json::json!(citations))
},
usage: usage_payload,
// Terminal delivery events are emitted outside the seq-stamping
// progress bridge; leave `seq` unset (older clients ignore it).
seq: None,
});
}
+411 -277
View File
@@ -34,6 +34,7 @@ fn flush_interim_narration(
client_id: &str,
thread_id: &str,
request_id: &str,
emit_seq: &mut u64,
) {
let text = std::mem::take(buffer);
let Some(narration) = interim_narration_text(&text) else {
@@ -45,15 +46,31 @@ fn flush_interim_narration(
narration.chars().count(),
request_id,
);
publish_web_channel_event(WebChannelEvent {
event: "chat_interim".to_string(),
client_id: client_id.to_string(),
thread_id: thread_id.to_string(),
request_id: request_id.to_string(),
full_response: Some(narration),
round: Some(round),
..Default::default()
});
publish_seq_stamped(
emit_seq,
WebChannelEvent {
event: "chat_interim".to_string(),
client_id: client_id.to_string(),
thread_id: thread_id.to_string(),
request_id: request_id.to_string(),
full_response: Some(narration),
round: Some(round),
..Default::default()
},
);
}
/// Stamp a per-request monotonic sequence number on an outgoing web-channel
/// event and publish it. `request_id` is already carried on every
/// [`WebChannelEvent`]; `seq` is the additive ordering key the frontend uses to
/// dedup replayed vs live events by `(request_id, seq)` and to order them
/// identically to the persisted turn-state snapshot
/// (conversations-timeline-refactor, Phase 4). The counter advances once per
/// emitted event so each `(request_id, seq)` pair is unique within a turn.
fn publish_seq_stamped(next_seq: &mut u64, mut event: WebChannelEvent) {
event.seq = Some(*next_seq);
*next_seq = next_seq.saturating_add(1);
publish_web_channel_event(event);
}
/// The trimmed narration to surface as an interim bubble, or `None` when it is
@@ -340,6 +357,10 @@ pub(crate) fn spawn_progress_bridge(
// (it belongs to the terminal round, which ends with no tool call).
let mut pending_narration = String::new();
let mut events_seen: u64 = 0;
// Per-request monotonic ordering key stamped on every emitted
// web-channel event (see `publish_seq_stamped`). Unique per emission so
// the frontend can dedup by `(request_id, seq)`.
let mut emit_seq: u64 = 0;
let mut parent_completed = false;
let mut parent_tool_count: u64 = 0;
let mut child_tool_counts: HashMap<String, u64> = HashMap::new();
@@ -444,7 +465,7 @@ pub(crate) fn spawn_progress_bridge(
thread_id,
request_id,
);
publish_web_channel_event(WebChannelEvent {
publish_seq_stamped(&mut emit_seq, WebChannelEvent {
event: "inference_heartbeat".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
@@ -580,13 +601,16 @@ pub(crate) fn spawn_progress_bridge(
payload: json!({ "threadId": thread_id, "clientId": client_id }),
},
);
publish_web_channel_event(WebChannelEvent {
event: "inference_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "inference_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
..Default::default()
},
);
}
AgentProgress::IterationStarted {
iteration,
@@ -594,15 +618,18 @@ pub(crate) fn spawn_progress_bridge(
} => {
round = iteration;
parent_max_iterations = max_iterations;
publish_web_channel_event(WebChannelEvent {
event: "iteration_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!("Iteration {iteration}/{max_iterations}")),
round: Some(iteration),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "iteration_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!("Iteration {iteration}/{max_iterations}")),
round: Some(iteration),
..Default::default()
},
);
}
AgentProgress::ToolCallStarted {
call_id,
@@ -621,6 +648,7 @@ pub(crate) fn spawn_progress_bridge(
&client_id,
&thread_id,
&request_id,
&mut emit_seq,
);
parent_tool_count += 1;
ledger_append_event(
@@ -643,20 +671,23 @@ pub(crate) fn spawn_progress_bridge(
..Default::default()
},
);
publish_web_channel_event(WebChannelEvent {
event: "tool_call".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some("web_channel".to_string()),
args: Some(arguments),
round: Some(iteration),
tool_call_id: Some(call_id),
tool_display_label: display_label,
tool_display_detail: display_detail,
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "tool_call".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some("web_channel".to_string()),
args: Some(arguments),
round: Some(iteration),
tool_call_id: Some(call_id),
tool_display_label: display_label,
tool_display_detail: display_detail,
..Default::default()
},
);
}
AgentProgress::ToolCallCompleted {
call_id,
@@ -687,24 +718,27 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "tool_result".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some("web_channel".to_string()),
// Forward the real tool result (size-capped) so the UI
// can render tool output — mirrors the subagent
// `subagent_tool_result` path. Frontends that only
// need size/timing read the ledger telemetry instead.
output: Some(cap_wire_output(output)),
success: Some(success),
round: Some(iteration),
tool_call_id: Some(call_id),
failure: failure_json,
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "tool_result".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some("web_channel".to_string()),
// Forward the real tool result (size-capped) so the UI
// can render tool output — mirrors the subagent
// `subagent_tool_result` path. Frontends that only
// need size/timing read the ledger telemetry instead.
output: Some(cap_wire_output(output)),
success: Some(success),
round: Some(iteration),
tool_call_id: Some(call_id),
failure: failure_json,
..Default::default()
},
);
}
AgentProgress::SubagentSpawned {
agent_id,
@@ -770,25 +804,28 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_spawned".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!("Sub-agent '{label}' spawned")),
tool_name: Some(agent_id),
skill_id: Some(task_id),
round: Some(round),
subagent: Some(SubagentProgressDetail {
mode: Some(mode),
dedicated_thread: Some(dedicated_thread),
prompt_chars: Some(prompt_chars as u64),
worker_thread_id,
display_name,
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_spawned".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!("Sub-agent '{label}' spawned")),
tool_name: Some(agent_id),
skill_id: Some(task_id),
round: Some(round),
subagent: Some(SubagentProgressDetail {
mode: Some(mode),
dedicated_thread: Some(dedicated_thread),
prompt_chars: Some(prompt_chars as u64),
worker_thread_id,
display_name,
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentCompleted {
agent_id,
@@ -851,29 +888,36 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_completed".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!(
"Sub-agent '{agent_id}' completed in {elapsed_ms}ms"
)),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(true),
round: Some(round),
subagent: Some(SubagentProgressDetail {
elapsed_ms: Some(elapsed_ms),
iterations: Some(iterations),
output_chars: Some(output_chars as u64),
// Worktree isolation metadata (#3376) — drives the
// inline subagent worktree row's open/diff/remove
// actions. All `None`/absent for non-isolated workers.
..subagent_worktree_detail(worktree_path, changed_files, dirty_status)
}),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_completed".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(format!(
"Sub-agent '{agent_id}' completed in {elapsed_ms}ms"
)),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(true),
round: Some(round),
subagent: Some(SubagentProgressDetail {
elapsed_ms: Some(elapsed_ms),
iterations: Some(iterations),
output_chars: Some(output_chars as u64),
// Worktree isolation metadata (#3376) — drives the
// inline subagent worktree row's open/diff/remove
// actions. All `None`/absent for non-isolated workers.
..subagent_worktree_detail(
worktree_path,
changed_files,
dirty_status,
)
}),
..Default::default()
},
);
}
AgentProgress::SubagentFailed {
agent_id,
@@ -920,18 +964,21 @@ pub(crate) fn spawn_progress_bridge(
payload: json!({ "agentId": agent_id, "error": error }),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_failed".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(error),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(false),
round: Some(round),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_failed".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(error),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(false),
round: Some(round),
..Default::default()
},
);
}
AgentProgress::SubagentAwaitingUser {
agent_id,
@@ -995,22 +1042,25 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_awaiting_user".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(question),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(true),
round: Some(round),
subagent: Some(SubagentProgressDetail {
worker_thread_id,
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_awaiting_user".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(question),
tool_name: Some(agent_id),
skill_id: Some(task_id),
success: Some(true),
round: Some(round),
subagent: Some(SubagentProgressDetail {
worker_thread_id,
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentIterationStarted {
agent_id,
@@ -1019,30 +1069,35 @@ pub(crate) fn spawn_progress_bridge(
max_iterations,
extended_policy,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_iteration_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(if extended_policy {
format!("Sub-agent '{agent_id}' step {iteration}")
} else {
format!("Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}")
}),
tool_name: Some(agent_id),
skill_id: Some(task_id),
round: Some(round),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
child_max_iterations: if extended_policy {
None
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_iteration_start".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
message: Some(if extended_policy {
format!("Sub-agent '{agent_id}' step {iteration}")
} else {
Some(max_iterations)
},
format!(
"Sub-agent '{agent_id}' iteration {iteration}/{max_iterations}"
)
}),
tool_name: Some(agent_id),
skill_id: Some(task_id),
round: Some(round),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
child_max_iterations: if extended_policy {
None
} else {
Some(max_iterations)
},
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentToolCallStarted {
agent_id,
@@ -1077,33 +1132,36 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_tool_call".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some(task_id.clone()),
// The child's tool arguments, so the UI can show what
// the sub-agent actually did (issue: subagent drawer
// detail). Skipped from the wire when `null`.
args: if arguments.is_null() {
None
} else {
Some(arguments)
},
round: Some(round),
tool_call_id: Some(call_id),
tool_display_label: display_label,
tool_display_detail: display_detail,
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_tool_call".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some(task_id.clone()),
// The child's tool arguments, so the UI can show what
// the sub-agent actually did (issue: subagent drawer
// detail). Skipped from the wire when `null`.
args: if arguments.is_null() {
None
} else {
Some(arguments)
},
round: Some(round),
tool_call_id: Some(call_id),
tool_display_label: display_label,
tool_display_detail: display_detail,
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentToolCallCompleted {
agent_id,
@@ -1139,32 +1197,35 @@ pub(crate) fn spawn_progress_bridge(
}),
},
);
publish_web_channel_event(WebChannelEvent {
event: "subagent_tool_result".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some(task_id.clone()),
success: Some(success),
round: Some(round),
tool_call_id: Some(call_id),
// The child's actual tool output, so the drawer can show
// *what came back* (not just a char count). Capped to a
// bounded size for the wire (#4007); `output_chars` +
// `elapsed_ms` still ride along in `subagent` below.
output: Some(cap_wire_output(output)),
failure: failure_json,
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
elapsed_ms: Some(elapsed_ms),
output_chars: Some(output_chars as u64),
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_tool_result".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: Some(tool_name),
skill_id: Some(task_id.clone()),
success: Some(success),
round: Some(round),
tool_call_id: Some(call_id),
// The child's actual tool output, so the drawer can show
// *what came back* (not just a char count). Capped to a
// bounded size for the wire (#4007); `output_chars` +
// `elapsed_ms` still ride along in `subagent` below.
output: Some(cap_wire_output(output)),
failure: failure_json,
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
elapsed_ms: Some(elapsed_ms),
output_chars: Some(output_chars as u64),
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentTextDelta {
agent_id,
@@ -1172,23 +1233,26 @@ pub(crate) fn spawn_progress_bridge(
delta,
iteration,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_text_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("text".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_text_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("text".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::SubagentThinkingDelta {
agent_id,
@@ -1196,23 +1260,26 @@ pub(crate) fn spawn_progress_bridge(
delta,
iteration,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_thinking_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("thinking".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "subagent_thinking_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("thinking".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
..Default::default()
}),
..Default::default()
}),
..Default::default()
});
},
);
}
AgentProgress::TaskBoardUpdated { board } => {
log::debug!(
@@ -1222,43 +1289,52 @@ pub(crate) fn spawn_progress_bridge(
request_id,
board.cards.len()
);
publish_web_channel_event(WebChannelEvent {
event: "task_board_updated".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
task_board: Some(serde_json::to_value(board).unwrap_or_else(
|_| serde_json::json!({ "threadId": thread_id, "cards": [] }),
)),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "task_board_updated".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
task_board: Some(serde_json::to_value(board).unwrap_or_else(
|_| serde_json::json!({ "threadId": thread_id, "cards": [] }),
)),
..Default::default()
},
);
}
AgentProgress::TextDelta { delta, iteration } => {
// Buffer the round's narration so it can be flushed as an
// interim bubble if a tool call closes this round.
pending_narration.push_str(&delta);
publish_web_channel_event(WebChannelEvent {
event: "text_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("text".to_string()),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "text_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("text".to_string()),
..Default::default()
},
);
}
AgentProgress::ThinkingDelta { delta, iteration } => {
publish_web_channel_event(WebChannelEvent {
event: "thinking_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("thinking".to_string()),
..Default::default()
});
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "thinking_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("thinking".to_string()),
..Default::default()
},
);
}
AgentProgress::ToolCallArgsDelta {
call_id,
@@ -1266,23 +1342,26 @@ pub(crate) fn spawn_progress_bridge(
delta,
iteration,
} => {
publish_web_channel_event(WebChannelEvent {
event: "tool_args_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: if tool_name.is_empty() {
None
} else {
Some(tool_name)
publish_seq_stamped(
&mut emit_seq,
WebChannelEvent {
event: "tool_args_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
tool_name: if tool_name.is_empty() {
None
} else {
Some(tool_name)
},
skill_id: Some("web_channel".to_string()),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("tool_args".to_string()),
tool_call_id: Some(call_id),
..Default::default()
},
skill_id: Some("web_channel".to_string()),
round: Some(iteration),
delta: Some(delta),
delta_kind: Some("tool_args".to_string()),
tool_call_id: Some(call_id),
..Default::default()
});
);
}
AgentProgress::TurnCompleted { iterations } => {
parent_completed = true;
@@ -1790,4 +1869,59 @@ mod tests {
drop(tx);
}
/// Every event the bridge emits carries an additive per-request monotonic
/// `seq` (conversations-timeline-refactor, Phase 4), so the frontend can
/// dedup replayed vs live events by `(request_id, seq)` and order them
/// identically to the persisted snapshot. Drive a short deterministic
/// sequence and assert the emitted seqs are present and strictly increasing.
#[tokio::test]
async fn stamps_monotonic_seq_on_emitted_events() {
let mut events = super::super::event_bus::subscribe_web_channel_events();
let thread_id = "thread-seq-stamp";
let request_id = "req-seq-stamp";
let tx = spawn_test_bridge(thread_id, request_id);
// Each of these emits exactly one web-channel event: inference_start,
// tool_call, tool_result (the fast test never trips the 20s heartbeat).
tx.send(AgentProgress::TurnStarted).await.unwrap();
tx.send(AgentProgress::ToolCallStarted {
call_id: "tc-1".into(),
tool_name: "shell".into(),
arguments: serde_json::json!({}),
iteration: 1,
display_label: None,
display_detail: None,
})
.await
.unwrap();
tx.send(AgentProgress::ToolCallCompleted {
call_id: "tc-1".into(),
tool_name: "shell".into(),
success: true,
output_chars: 0,
output: String::new(),
arguments: None,
elapsed_ms: 5,
iteration: 1,
failure: None,
})
.await
.unwrap();
let mut seqs = Vec::new();
for _ in 0..3 {
let ev = recv_for_thread(&mut events, thread_id).await;
assert_eq!(ev.request_id, request_id);
seqs.push(ev.seq.expect("every emitted event carries a seq"));
}
// The very first emitted event starts the per-request counter at 0.
assert_eq!(seqs[0], 0, "seq counter starts at 0 for the request");
assert!(
seqs.windows(2).all(|w| w[0] < w[1]),
"emitted seqs must be strictly increasing, got {seqs:?}"
);
drop(tx);
}
}
+44 -26
View File
@@ -163,39 +163,57 @@ pub(crate) async fn run_chat_task(
),
};
// Cold-boot resume from the conversation JSONL.
// Cold-boot resume. Prefer the full-fidelity `session_raw/{stem}.jsonl`
// transcript (tool calls, tool-role results, reasoning) routed by thread
// id — the model must not "forget" its tool interactions across an app
// restart. Only fall back to the lossy conversation-log prose pairs when
// no root transcript exists for the thread or it fails to load; the two
// sources overlap (user prompts + final assistant text), so we take one
// or the other, never both, to avoid duplicated context.
if was_built_fresh {
match crate::openhuman::memory_conversations::get_messages(
config.workspace_dir.clone(),
thread_id,
) {
Ok(prior_messages) if !prior_messages.is_empty() => {
let pairs: Vec<(String, String)> = prior_messages
.into_iter()
.map(|m| (m.sender, m.content))
.collect();
if let Err(err) = agent.seed_resume_from_messages(pairs, message) {
if agent.seed_resume_from_thread_transcript(thread_id) {
log::info!(
"[web-channel] cold-boot resumed thread={} from full-fidelity session transcript",
thread_id
);
} else {
log::debug!(
"[web-channel] no usable session transcript for thread={} — seeding resume \
from conversation-log prose",
thread_id
);
match crate::openhuman::memory_conversations::get_messages(
config.workspace_dir.clone(),
thread_id,
) {
Ok(prior_messages) if !prior_messages.is_empty() => {
let pairs: Vec<(String, String)> = prior_messages
.into_iter()
.map(|m| (m.sender, m.content))
.collect();
if let Err(err) = agent.seed_resume_from_messages(pairs, message) {
log::warn!(
"[web-channel] failed to seed agent resume from conversation log \
thread={} err={}",
thread_id,
err
);
}
}
Ok(_) => {
log::debug!(
"[web-channel] no prior messages to seed for thread={} — first turn",
thread_id
);
}
Err(err) => {
log::warn!(
"[web-channel] failed to seed agent resume from conversation log \
thread={} err={}",
"[web-channel] failed to read conversation log for resume thread={} err={}",
thread_id,
err
);
}
}
Ok(_) => {
log::debug!(
"[web-channel] no prior messages to seed for thread={} — first turn",
thread_id
);
}
Err(err) => {
log::warn!(
"[web-channel] failed to read conversation log for resume thread={} err={}",
thread_id,
err
);
}
}
}