mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent-world): stop interrupted subagent rows pulsing forever (#3990)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user