Files
openhuman/app/src/store/chatRuntimeSlice.test.ts
T

1059 lines
37 KiB
TypeScript

import { configureStore } from '@reduxjs/toolkit';
import { describe, expect, it } from 'vitest';
import type { AgentRun, AgentRunStatus, PersistedTurnState } from '../types/turnState';
import chatRuntimeReducer, {
appendProcessingProse,
beginInferenceTurn,
clearAllChatRuntime,
clearQueueStatusForThread,
clearRuntimeForThread,
hydrateRuntimeFromRunLedger,
hydrateRuntimeFromSnapshot,
hydrateThreadUsage,
markInferenceTurnStreaming,
type QueueStatus,
recordChatTurnUsage,
resetSessionTokenUsage,
setPendingApprovalForThread,
setQueueStatusForThread,
setStreamingAssistantForThread,
setToolTimelineForThread,
setWorkflowProposalForThread,
} from './chatRuntimeSlice';
function makeRun(id: string, status: AgentRunStatus): AgentRun {
return {
id,
kind: 'subagent',
status,
agentId: 'tinyplace_agent',
metadata: { displayName: 'Tinyplace Agent' },
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
function makeInterruptedSnapshot(
threadId: string,
toolTimeline: PersistedTurnState['toolTimeline']
): PersistedTurnState {
return {
threadId,
requestId: 'req-1',
lifecycle: 'interrupted',
iteration: 3,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline,
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
function makeStore() {
return configureStore({ reducer: { chatRuntime: chatRuntimeReducer } });
}
describe('chatRuntimeSlice recordChatTurnUsage', () => {
it('accumulates tokens, cost, and context window across turns', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 1000,
outputTokens: 200,
cachedTokens: 50,
costUsd: 0.012,
contextWindow: 200_000,
})
);
store.dispatch(
recordChatTurnUsage({
inputTokens: 500,
outputTokens: 100,
cachedTokens: 10,
costUsd: 0.008,
contextWindow: 200_000,
})
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(1500);
expect(usage.outputTokens).toBe(300);
expect(usage.cachedTokens).toBe(60);
expect(usage.costUsd).toBeCloseTo(0.02, 6);
expect(usage.turns).toBe(2);
expect(usage.contextWindow).toBe(200_000);
// Context gauge tracks the latest turn's input+output, not the running sum.
expect(usage.lastTurnContextUsed).toBe(600);
});
it('rolls sub-agent spend into a per-archetype breakdown keyed by agentId', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 100,
outputTokens: 20,
subAgents: [
{ agentId: 'researcher', inputTokens: 40, outputTokens: 10, costUsd: 0.001 },
{ agentId: 'coder', inputTokens: 80, outputTokens: 30, costUsd: 0.003 },
],
})
);
store.dispatch(
recordChatTurnUsage({
inputTokens: 50,
outputTokens: 10,
subAgents: [{ agentId: 'researcher', inputTokens: 60, outputTokens: 5, costUsd: 0.002 }],
})
);
const subs = store.getState().chatRuntime.sessionTokenUsage.subAgents;
expect(subs.researcher).toEqual({
agentId: 'researcher',
inputTokens: 100,
outputTokens: 15,
costUsd: 0.003,
runs: 2,
});
expect(subs.coder.runs).toBe(1);
expect(subs.coder.inputTokens).toBe(80);
});
it('excludes sub-agent tokens from the context gauge numerator (#4271)', () => {
const store = makeStore();
// Core sends combined parent+sub-agent turn totals; the gauge must reflect
// the orchestrator thread's own window only, never the sum across agents.
store.dispatch(
recordChatTurnUsage({
inputTokens: 1_000_000,
outputTokens: 50_000,
contextWindow: 1_000_000,
subAgents: [
{ agentId: 'researcher', inputTokens: 600_000, outputTokens: 30_000, costUsd: 0.6 },
{ agentId: 'context_scout', inputTokens: 150_000, outputTokens: 10_000, costUsd: 0.15 },
],
})
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
// orchestrator-only = (1_000_000 + 50_000) - (600_000+30_000 + 150_000+10_000)
expect(usage.lastTurnContextUsed).toBe(260_000);
// Gauge stays within its window: 260_000 / 1_000_000 = 26% ≤ 100%.
expect(usage.lastTurnContextUsed).toBeLessThanOrEqual(usage.contextWindow);
});
it('keeps the full turn as the gauge numerator with no sub-agents (#4271)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 800, outputTokens: 120, contextWindow: 200_000 })
);
// Single-agent path is unchanged: nothing to subtract.
expect(store.getState().chatRuntime.sessionTokenUsage.lastTurnContextUsed).toBe(920);
});
it('clamps the gauge numerator to zero when sub-agents exceed the turn total (#4271)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 50,
outputTokens: 10,
subAgents: [{ agentId: 'researcher', inputTokens: 100, outputTokens: 20, costUsd: 0.001 }],
})
);
expect(store.getState().chatRuntime.sessionTokenUsage.lastTurnContextUsed).toBe(0);
});
it('keeps the prior context window when a turn reports an unknown (0) window', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 10, outputTokens: 5, contextWindow: 128_000 })
);
store.dispatch(recordChatTurnUsage({ inputTokens: 10, outputTokens: 5, contextWindow: 0 }));
expect(store.getState().chatRuntime.sessionTokenUsage.contextWindow).toBe(128_000);
});
it('coerces non-finite / negative inputs to zero', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: Number.NaN, outputTokens: -50, costUsd: -1 })
);
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(0);
expect(usage.outputTokens).toBe(0);
expect(usage.costUsd).toBe(0);
expect(usage.turns).toBe(1);
});
it('resetSessionTokenUsage clears all accumulated usage', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({
inputTokens: 100,
outputTokens: 20,
costUsd: 0.01,
subAgents: [{ agentId: 'researcher', inputTokens: 1, outputTokens: 1, costUsd: 0.001 }],
})
);
store.dispatch(resetSessionTokenUsage());
const usage = store.getState().chatRuntime.sessionTokenUsage;
expect(usage.inputTokens).toBe(0);
expect(usage.costUsd).toBe(0);
expect(usage.turns).toBe(0);
expect(usage.subAgents).toEqual({});
});
it('routes a turn with a threadId into that thread bucket (and the global)', () => {
const store = makeStore();
store.dispatch(
recordChatTurnUsage({ inputTokens: 100, outputTokens: 20, costUsd: 0.01, threadId: 'thr-a' })
);
store.dispatch(
recordChatTurnUsage({ inputTokens: 50, outputTokens: 10, costUsd: 0.005, threadId: 'thr-b' })
);
const { usageByThread, sessionTokenUsage } = store.getState().chatRuntime;
expect(usageByThread['thr-a'].inputTokens).toBe(100);
expect(usageByThread['thr-a'].costUsd).toBeCloseTo(0.01, 6);
expect(usageByThread['thr-b'].inputTokens).toBe(50);
// Global aggregate still sums both threads.
expect(sessionTokenUsage.inputTokens).toBe(150);
});
it('hydrateThreadUsage seeds a thread bucket and live turns accumulate on top', () => {
const store = makeStore();
store.dispatch(
hydrateThreadUsage({
threadId: 'thr-a',
inputTokens: 1000,
outputTokens: 300,
cachedTokens: 40,
costUsd: 0.02,
turns: 3,
contextWindow: 1_000_000,
lastTurnInputTokens: 400,
lastTurnOutputTokens: 120,
subAgents: [
{ agentId: 'coder', inputTokens: 300, outputTokens: 80, costUsd: 0.006, runs: 2 },
],
})
);
let bucket = store.getState().chatRuntime.usageByThread['thr-a'];
expect(bucket.inputTokens).toBe(1000);
expect(bucket.turns).toBe(3);
expect(bucket.contextWindow).toBe(1_000_000);
expect(bucket.lastTurnContextUsed).toBe(520);
// Sub-agent breakdown reconstructed from persisted transcripts.
expect(bucket.subAgents.coder).toEqual({
agentId: 'coder',
inputTokens: 300,
outputTokens: 80,
costUsd: 0.006,
runs: 2,
});
// A live turn for the same thread adds on top of the seeded base.
store.dispatch(
recordChatTurnUsage({ inputTokens: 200, outputTokens: 50, costUsd: 0.004, threadId: 'thr-a' })
);
bucket = store.getState().chatRuntime.usageByThread['thr-a'];
expect(bucket.inputTokens).toBe(1200);
expect(bucket.turns).toBe(4);
expect(bucket.costUsd).toBeCloseTo(0.024, 6);
});
});
describe('chatRuntimeSlice queue status', () => {
it('sets queue status for a thread', () => {
const store = makeStore();
const status: QueueStatus = { active: true, steers: 1, followups: 2, collects: 0, total: 3 };
store.dispatch(setQueueStatusForThread({ threadId: 't1', status }));
expect(store.getState().chatRuntime.queueStatusByThread['t1']).toEqual(status);
});
it('clears queue status for a thread', () => {
const store = makeStore();
const status: QueueStatus = { active: true, steers: 1, followups: 0, collects: 0, total: 1 };
store.dispatch(setQueueStatusForThread({ threadId: 't1', status }));
store.dispatch(clearQueueStatusForThread({ threadId: 't1' }));
expect(store.getState().chatRuntime.queueStatusByThread['t1']).toBeUndefined();
});
it('clearRuntimeForThread removes queue status', () => {
const store = makeStore();
const status: QueueStatus = { active: true, steers: 1, followups: 0, collects: 0, total: 1 };
store.dispatch(setQueueStatusForThread({ threadId: 't1', status }));
store.dispatch(clearRuntimeForThread({ threadId: 't1' }));
expect(store.getState().chatRuntime.queueStatusByThread['t1']).toBeUndefined();
});
it('clearAllChatRuntime removes all queue statuses', () => {
const store = makeStore();
store.dispatch(
setQueueStatusForThread({
threadId: 't1',
status: { active: true, steers: 1, followups: 0, collects: 0, total: 1 },
})
);
store.dispatch(
setQueueStatusForThread({
threadId: 't2',
status: { active: true, steers: 0, followups: 1, collects: 0, total: 1 },
})
);
// Also seed a processing transcript so the clear covers it too (a global
// reset must not leave stale "View processing" prose behind).
store.dispatch(
appendProcessingProse({ threadId: 't1', kind: 'narration', round: 1, delta: 'thinking…' })
);
expect(store.getState().chatRuntime.processingByThread.t1).toHaveLength(1);
store.dispatch(clearAllChatRuntime());
expect(store.getState().chatRuntime.queueStatusByThread).toEqual({});
expect(store.getState().chatRuntime.processingByThread).toEqual({});
});
it('updates queue status when set again', () => {
const store = makeStore();
store.dispatch(
setQueueStatusForThread({
threadId: 't1',
status: { active: true, steers: 1, followups: 0, collects: 0, total: 1 },
})
);
store.dispatch(
setQueueStatusForThread({
threadId: 't1',
status: { active: true, steers: 0, followups: 0, collects: 0, total: 0 },
})
);
expect(store.getState().chatRuntime.queueStatusByThread['t1']).toEqual({
active: true,
steers: 0,
followups: 0,
collects: 0,
total: 0,
});
});
it('settles orphaned running rows when hydrating an interrupted snapshot', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedSnapshot('t1', [
{
id: 't1:subagent:s1:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'running',
subagent: {
taskId: 's1',
agentId: 'tinyplace_agent',
status: 'running',
toolCalls: [],
},
},
{
id: 't1:subagent:s2:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'success',
subagent: {
taskId: 's2',
agentId: 'tinyplace_agent',
status: 'completed',
toolCalls: [],
},
},
{
id: 't1:subagent:s3:tinyplace_agent',
name: 'subagent:tinyplace_agent',
round: 1,
status: 'error',
subagent: { taskId: 's3', agentId: 'tinyplace_agent', status: 'failed', toolCalls: [] },
},
]),
})
);
const timeline = store.getState().chatRuntime.toolTimelineByThread['t1'];
// The dangling 'running' row becomes terminal 'cancelled' (no live driver to settle it)…
expect(timeline[0].status).toBe('cancelled');
expect(timeline[0].subagent?.status).toBe('cancelled');
// …while already-terminal rows are left untouched.
expect(timeline[1].status).toBe('success');
expect(timeline[1].subagent?.status).toBe('completed');
expect(timeline[2].status).toBe('error');
expect(timeline[2].subagent?.status).toBe('failed');
});
it('renders interrupted run-ledger rows as muted (cancelled), reserving error for failed', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromRunLedger({
threadId: 't1',
runs: [
makeRun('sub-interrupted', 'interrupted'),
makeRun('sub-failed', 'failed'),
makeRun('sub-completed', 'completed'),
],
})
);
const byId = Object.fromEntries(
store.getState().chatRuntime.toolTimelineByThread['t1'].map(e => [e.id, e.status])
);
// Orphaned (interrupted) background runs are terminal but NOT user-facing
// errors — muted, not alarming red.
expect(byId['subagent:sub-interrupted']).toBe('cancelled');
// A genuine failure still surfaces as an error.
expect(byId['subagent:sub-failed']).toBe('error');
expect(byId['subagent:sub-completed']).toBe('success');
});
it('does not duplicate a live subagent row whose taskId is already on screen', () => {
const store = makeStore();
// Live row created by the socket path — a different entry id scheme than
// the ledger's `subagent:<runId>`, but the same delegation taskId.
store.dispatch(
setToolTimelineForThread({
threadId: 't-dup',
entries: [
{
id: 't-dup:subagent:run-1:spawn_subagent',
name: 'subagent:tinyplace_agent',
round: 1,
seq: 0,
status: 'running',
subagent: { taskId: 'run-1', agentId: 'tinyplace_agent', toolCalls: [] },
},
],
})
);
store.dispatch(
hydrateRuntimeFromRunLedger({
threadId: 't-dup',
runs: [makeRun('run-1', 'running'), makeRun('run-2', 'completed')],
})
);
const timeline = store.getState().chatRuntime.toolTimelineByThread['t-dup'];
// run-1 is already represented by the live row; only run-2 is added.
expect(timeline).toHaveLength(2);
expect(timeline.filter(e => e.subagent?.taskId === 'run-1')).toHaveLength(1);
expect(timeline.some(e => e.id === 'subagent:run-2')).toBe(true);
});
it('settles the parent row but preserves an awaiting_user subagent on interrupt', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({
snapshot: makeInterruptedSnapshot('t2', [
{
id: 't2:subagent:s1:researcher',
name: 'subagent:researcher',
round: 1,
// Core keeps the row `running` while the child is paused for the user.
status: 'running',
subagent: {
taskId: 's1',
agentId: 'researcher',
status: 'awaiting_user',
workerThreadId: 'worker-1',
toolCalls: [],
},
},
]),
})
);
const row = store.getState().chatRuntime.toolTimelineByThread['t2'][0];
// The row stops pulsing (status drives agentNameTone)…
expect(row.status).toBe('cancelled');
// …but the truthful "was awaiting user" child state is kept, not clobbered.
expect(row.subagent?.status).toBe('awaiting_user');
expect(row.subagent?.workerThreadId).toBe('worker-1');
});
it('isolates queue status across threads', () => {
const store = makeStore();
store.dispatch(
setQueueStatusForThread({
threadId: 't1',
status: { active: true, steers: 1, followups: 0, collects: 0, total: 1 },
})
);
store.dispatch(
setQueueStatusForThread({
threadId: 't2',
status: { active: true, steers: 0, followups: 2, collects: 0, total: 2 },
})
);
expect(store.getState().chatRuntime.queueStatusByThread['t1']?.steers).toBe(1);
expect(store.getState().chatRuntime.queueStatusByThread['t2']?.followups).toBe(2);
});
});
describe('hydrateRuntimeFromSnapshot — sub-agent prose persistence', () => {
it('carries live sub-agent thoughts across rehydration (matched by taskId)', () => {
const store = makeStore();
// Live in-memory row: sub-agent with streamed reasoning + a tool call.
// Live and persisted rows use different entry ids, so the merge matches
// on the sub-agent taskId.
store.dispatch(
setToolTimelineForThread({
threadId: 't9',
entries: [
{
id: 't9:subagent:task-x:spawn_subagent',
name: 'subagent:researcher',
round: 1,
seq: 0,
status: 'running',
subagent: {
taskId: 'task-x',
agentId: 'researcher',
toolCalls: [],
transcript: [
{ kind: 'thinking', iteration: 1, text: 'let me search the inbox' },
{
kind: 'tool',
iteration: 1,
callId: 'c1',
toolName: 'web_search',
status: 'success',
},
],
},
},
],
})
);
// Snapshot rebuilds the sub-agent transcript from tool calls only (no
// prose) and uses the persisted entry id `subagent:<taskId>`.
const snapshot: PersistedTurnState = {
threadId: 't9',
requestId: 'req-1',
lifecycle: 'streaming',
iteration: 1,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [
{
id: 'subagent:task-x',
name: 'subagent:researcher',
round: 1,
status: 'running',
subagent: {
taskId: 'task-x',
agentId: 'researcher',
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['t9'].find(e => e.subagent?.taskId === 'task-x');
const transcript = row?.subagent?.transcript ?? [];
// The streamed thought survives the rehydration instead of being clobbered
// by the prose-less snapshot.
const thinking = transcript.find(i => i.kind === 'thinking');
expect(thinking && 'text' in thinking ? thinking.text : undefined).toBe(
'let me search the inbox'
);
});
it('replays a persisted sub-agent transcript on a settled turn (no live data)', () => {
const store = makeStore();
// No live entries seeded — this is the settled / reloaded case. The
// snapshot itself now carries the sub-agent prose transcript.
const snapshot: PersistedTurnState = {
threadId: 't10',
requestId: 'req-1',
lifecycle: 'completed',
iteration: 2,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [
{
id: 'subagent:task-y',
name: 'subagent:researcher',
round: 1,
status: 'success',
subagent: {
taskId: 'task-y',
agentId: 'researcher',
toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }],
transcript: [
{ kind: 'thinking', iteration: 1, text: 'planning the search' },
{
kind: 'tool',
iteration: 1,
callId: 'c1',
toolName: 'web_search',
status: 'success',
},
{ kind: 'text', iteration: 1, text: 'here is the summary' },
],
},
},
],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot }));
const row = store
.getState()
.chatRuntime.toolTimelineByThread['t10'].find(e => e.subagent?.taskId === 'task-y');
const transcript = row?.subagent?.transcript ?? [];
// The persisted prose survives a reload with no in-memory live data.
expect(transcript.map(i => i.kind)).toEqual(['thinking', 'tool', 'text']);
const thinking = transcript[0];
expect(thinking.kind === 'thinking' ? thinking.text : undefined).toBe('planning the search');
const text = transcript[2];
expect(text.kind === 'text' ? text.text : undefined).toBe('here is the summary');
});
});
describe('hydrateRuntimeFromSnapshot — live-driver guard', () => {
function makeStreamingSnapshot(threadId: string): PersistedTurnState {
return {
threadId,
requestId: 'req-stale',
lifecycle: 'streaming',
iteration: 1,
maxIterations: 10,
streamingText: '',
thinking: '',
// Flush-boundary snapshot: one lonely row, behind the live state.
toolTimeline: [{ id: 'c-old', name: 'web_search', round: 1, status: 'running' }],
taskBoard: {
threadId,
cards: [
{
id: 'card-1',
title: 'Do the thing',
status: 'in_progress',
order: 0,
updatedAt: '2026-06-23T00:00:00Z',
},
],
updatedAt: '2026-06-23T00:00:00Z',
},
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
it('does not clobber a thread with a live in-flight turn (tab-switch case)', () => {
const store = makeStore();
// Live driver: the user sent, events are streaming into Redux.
store.dispatch(beginInferenceTurn({ threadId: 't-live' }));
store.dispatch(markInferenceTurnStreaming({ threadId: 't-live' }));
store.dispatch(
setToolTimelineForThread({
threadId: 't-live',
entries: [
{
id: 'c1',
name: 'web_search',
round: 1,
seq: 0,
status: 'success',
result: 'found 3 hits',
},
{ id: 'c2', name: 'read_file', round: 2, seq: 0, status: 'running' },
],
})
);
store.dispatch(
setPendingApprovalForThread({
threadId: 't-live',
approval: { requestId: 'req-live', toolName: 'shell', message: 'Run `ls`?' },
})
);
// A thread-switch hydration lands with a stale flush-boundary snapshot.
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeStreamingSnapshot('t-live') }));
const state = store.getState().chatRuntime;
// The richer live timeline (2 rows, with a result) survives untouched…
expect(state.toolTimelineByThread['t-live'].map(e => e.id)).toEqual(['c1', 'c2']);
expect(state.toolTimelineByThread['t-live'][0].result).toBe('found 3 hits');
// …the pending approval card is not wiped mid-turn…
expect(state.pendingApprovalByThread['t-live']?.requestId).toBe('req-live');
// …and the lifecycle stays live.
expect(state.inferenceTurnLifecycleByThread['t-live']).toBe('streaming');
// The task board (monotonic, cheap) is still applied.
expect(state.taskBoardByThread['t-live']?.cards[0]?.id).toBe('card-1');
});
it('applies the snapshot when there is no live driver (cold boot / new window)', () => {
const store = makeStore();
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot: makeStreamingSnapshot('t-cold') }));
const state = store.getState().chatRuntime;
expect(state.toolTimelineByThread['t-cold'].map(e => e.id)).toEqual(['c-old']);
expect(state.inferenceTurnLifecycleByThread['t-cold']).toBe('streaming');
});
});
// Regression: `fetchAndHydrateTurnState` (via `hydrateRuntimeFromSnapshot`)
// fires on thread rehydration (e.g. the always-open Flows copilot re-opening
// a persisted thread, #4874). A workflow proposal is a client-only flag with
// no server-side record, so a rehydrate must not resurrect a *stale* one from
// a crashed prior session — but it must also not wipe a proposal the
// streaming/blocking path set THIS session, moments before a `completed`
// snapshot for the same settled turn lands. Only `interrupted` (genuine
// crashed-mid-flight cleanup) should clear it.
describe('hydrateRuntimeFromSnapshot — workflow proposal race guard', () => {
function makeProposal(name: string) {
return {
name,
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: { trigger: 'manual', steps: [] },
};
}
function makeSnapshot(
threadId: string,
lifecycle: PersistedTurnState['lifecycle']
): PersistedTurnState {
return {
threadId,
requestId: 'req-1',
lifecycle,
iteration: 3,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
it('clears a pending proposal on an interrupted (crashed prior-session) snapshot', () => {
const store = makeStore();
store.dispatch(
setWorkflowProposalForThread({ threadId: 't-crashed', proposal: makeProposal('Stale') })
);
store.dispatch(
hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-crashed', 'interrupted') })
);
expect(
store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-crashed']
).toBeUndefined();
});
it('preserves a pending proposal on a completed snapshot from this session', () => {
const store = makeStore();
// The streaming/blocking path just set this moments before the
// rehydration thunk's `completed` snapshot lands for the same turn.
store.dispatch(
setWorkflowProposalForThread({ threadId: 't-settled', proposal: makeProposal('Fresh') })
);
store.dispatch(
hydrateRuntimeFromSnapshot({ snapshot: makeSnapshot('t-settled', 'completed') })
);
expect(store.getState().chatRuntime.pendingWorkflowProposalsByThread['t-settled']).toEqual(
makeProposal('Fresh')
);
});
});
// Regression: a `completed` snapshot rehydration must not clobber streaming
// narration / a tool timeline that is fresher than the snapshot itself. This
// happens when the socket-disconnect reconciliation path (ChatRuntimeProvider)
// deliberately preserves `streamingAssistantByThread` across `endInferenceTurn`
// so a partial reply stays visible while the socket reconnects, and a
// `fetchAndHydrateTurnState` rehydration then lands with a `completed`
// snapshot for that same (now-settled) turn. Only `interrupted` (a genuine
// crash — nothing fresher can exist) should unconditionally clobber; a
// `completed` snapshot should only fill in when there is no live state to
// lose (cold boot / new window).
describe('hydrateRuntimeFromSnapshot — streaming/timeline race guard', () => {
function makeTimelineSnapshot(
threadId: string,
lifecycle: PersistedTurnState['lifecycle']
): PersistedTurnState {
return {
threadId,
requestId: 'req-snapshot',
lifecycle,
iteration: 3,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [{ id: 'c-snapshot', name: 'web_search', round: 1, status: 'success' }],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
}
it('does not wipe a fresher streaming/tool-timeline lane on a completed snapshot', () => {
const store = makeStore();
// The live driver already has state for this thread — e.g. the
// disconnect-reconciliation path kept the streaming bubble around while
// the socket reconnects.
store.dispatch(
setStreamingAssistantForThread({
threadId: 't-settled',
streaming: { requestId: 'req-live', content: 'partial reply so far', thinking: '' },
})
);
store.dispatch(
setToolTimelineForThread({
threadId: 't-settled',
entries: [{ id: 'c-live', name: 'read_file', round: 1, seq: 0, status: 'success' }],
})
);
store.dispatch(
hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-settled', 'completed') })
);
const state = store.getState().chatRuntime;
// The fresher live streaming bubble survives untouched…
expect(state.streamingAssistantByThread['t-settled']?.content).toBe('partial reply so far');
// …and so does the live tool timeline, rather than being replaced by the
// (behind) snapshot's single row.
expect(state.toolTimelineByThread['t-settled'].map(e => e.id)).toEqual(['c-live']);
});
it('clears the streaming/tool-timeline lanes on an interrupted snapshot (crash cleanup)', () => {
const store = makeStore();
store.dispatch(
setStreamingAssistantForThread({
threadId: 't-crashed',
streaming: { requestId: 'req-stale', content: 'stale partial reply', thinking: '' },
})
);
store.dispatch(
setToolTimelineForThread({
threadId: 't-crashed',
entries: [{ id: 'c-stale', name: 'read_file', round: 1, seq: 0, status: 'running' }],
})
);
store.dispatch(
hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-crashed', 'interrupted') })
);
const state = store.getState().chatRuntime;
expect(state.streamingAssistantByThread['t-crashed']).toBeUndefined();
expect(state.toolTimelineByThread['t-crashed'].map(e => e.id)).toEqual(['c-snapshot']);
});
it('still hydrates the timeline from a completed snapshot on cold boot (no prior live state)', () => {
const store = makeStore();
store.dispatch(
hydrateRuntimeFromSnapshot({ snapshot: makeTimelineSnapshot('t-cold-boot', 'completed') })
);
const state = store.getState().chatRuntime;
expect(state.streamingAssistantByThread['t-cold-boot']).toBeUndefined();
expect(state.toolTimelineByThread['t-cold-boot'].map(e => e.id)).toEqual(['c-snapshot']);
});
});
describe('hydrateRuntimeFromSnapshot — persisted tool result output', () => {
it('maps the persisted output onto parent and sub-agent rows as result', () => {
const store = makeStore();
const snapshot: PersistedTurnState = {
threadId: 't-out',
requestId: 'req-1',
lifecycle: 'completed',
iteration: 2,
maxIterations: 10,
streamingText: '',
thinking: '',
toolTimeline: [
{
id: 'c1',
name: 'web_search',
round: 1,
status: 'success',
output: 'top hit: openhuman.dev',
},
{
id: 'subagent:task-z',
name: 'subagent:researcher',
round: 2,
status: 'success',
subagent: {
taskId: 'task-z',
agentId: 'researcher',
toolCalls: [
{ callId: 'cc1', toolName: 'read_file', status: 'success', output: 'file body' },
],
},
},
],
startedAt: '2026-06-23T00:00:00Z',
updatedAt: '2026-06-23T00:00:00Z',
};
store.dispatch(hydrateRuntimeFromSnapshot({ snapshot }));
const timeline = store.getState().chatRuntime.toolTimelineByThread['t-out'];
expect(timeline[0].result).toBe('top hit: openhuman.dev');
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');
});
});