diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index 8a8b8448d..e56a4bb48 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -1,14 +1,47 @@ import { configureStore } from '@reduxjs/toolkit'; import { describe, expect, it } from 'vitest'; +import type { AgentRun, AgentRunStatus, PersistedTurnState } from '../types/turnState'; import chatRuntimeReducer, { clearAllChatRuntime, clearQueueStatusForThread, clearRuntimeForThread, + hydrateRuntimeFromRunLedger, + hydrateRuntimeFromSnapshot, type QueueStatus, setQueueStatusForThread, } 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 } }); } @@ -78,6 +111,109 @@ describe('chatRuntimeSlice queue status', () => { }); }); + 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('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( diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 9a173d39c..609e50053 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -401,6 +401,42 @@ function toolTimelineFromPersisted(entry: PersistedToolTimelineEntry): ToolTimel }; } +/** + * Settle a rehydrated tool/subagent row that has no live event driver. + * + * A turn-state snapshot is a point-in-time mirror: a row left at the + * non-terminal `running` status was still in-flight when the snapshot was + * written. When the owning turn was *interrupted* (the core process that was + * driving it is gone — see `mark_all_interrupted`), no `subagent_done` / + * `chat_done` event will ever arrive to flip it terminal, so the row would + * pulse forever — the agent-name blink is driven by the row `status` + * (`agentNameTone(entry.status)`; `running` pulses, `cancelled` is muted & + * static). Settle the row to `cancelled` — terminal, muted, not pulsing — + * mirroring `markSubagentCancelled`. + * + * `running` is the only non-terminal value the persisted *row* status can carry + * (`PersistedToolStatus` is `running | success | error`), so that single guard + * catches every orphan. + * + * The nested `subagent.status` is a richer enum: a subagent that emitted + * `SubagentAwaitingUser` is persisted with the row `running` but + * `subagent.status = 'awaiting_user'`. Only settle a child that is *itself* + * still `running`; leaving `awaiting_user` (and any other non-running child) + * intact preserves the truthful "was waiting for the user" history — and the + * pulse is already stopped by the row-level `cancelled` above. + */ +function settleOrphanedTimelineEntry(entry: ToolTimelineEntry): ToolTimelineEntry { + if (entry.status !== 'running') return entry; + return { + ...entry, + status: 'cancelled', + subagent: + entry.subagent && entry.subagent.status === 'running' + ? { ...entry.subagent, status: 'cancelled' } + : entry.subagent, + }; +} + function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntryStatus { switch (status) { case 'completed': @@ -408,8 +444,12 @@ function timelineStatusFromRun(status: AgentRun['status']): ToolTimelineEntrySta case 'cancelled': return 'cancelled'; case 'failed': - case 'interrupted': return 'error'; + case 'interrupted': + // Orphaned by a process exit (e.g. a detached subagent the core lost track + // of and settled on next boot) — terminal, but not a user-facing error. + // Render muted/static like `cancelled`, not alarming red. + return 'cancelled'; case 'awaiting_user': case 'paused': return 'awaiting_user'; @@ -864,7 +904,11 @@ const chatRuntimeSlice = createSlice({ if (snapshot.lifecycle === 'interrupted') { delete state.inferenceStatusByThread[threadId]; delete state.streamingAssistantByThread[threadId]; - state.toolTimelineByThread[threadId] = snapshot.toolTimeline.map(toolTimelineFromPersisted); + // No live driver remains for this turn — settle any in-flight rows so + // their agent names stop pulsing instead of blinking forever. + state.toolTimelineByThread[threadId] = snapshot.toolTimeline + .map(toolTimelineFromPersisted) + .map(settleOrphanedTimelineEntry); return; }