mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(transcript): lossless transcript restore — full-fidelity cold-boot resume, per-turn thinking hydration, seq envelope (#5077)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user