fix(desktop): reconcile stale H5 task state #1214

This commit is contained in:
程序员阿江(Relakkes)
2026-08-16 01:40:56 +08:00
parent 7eb406243e
commit e2e302e1b3
7 changed files with 130 additions and 5 deletions
+43
View File
@@ -6757,6 +6757,7 @@ describe('chatStore history mapping', () => {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
await vi.waitFor(() => {
@@ -6797,6 +6798,7 @@ describe('chatStore history mapping', () => {
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: ['agent-task-1'],
})
await vi.waitFor(() => {
@@ -6809,6 +6811,47 @@ describe('chatStore history mapping', () => {
).toBe('running')
})
it('settles a stale H5 SubAgent when reconnect says no background task is still active', async () => {
vi.mocked(sessionsApi.getMessages).mockClear()
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
messages: [],
taskNotifications: [],
})
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession({
chatState: 'idle',
backgroundAgentTasks: {
'agent-task-missed-terminal': {
taskId: 'agent-task-missed-terminal',
toolUseId: 'agent-tool-missed-terminal',
status: 'running',
taskType: 'local_agent',
description: 'Finished while H5 was offline',
startedAt: 1,
updatedAt: 2,
},
},
}),
},
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
await vi.waitFor(() => {
expect(sessionsApi.getMessages).toHaveBeenCalledWith(TEST_SESSION_ID)
expect(updateTabStatusMock).toHaveBeenLastCalledWith(TEST_SESSION_ID, 'idle')
})
expect(
useChatStore.getState().sessions[TEST_SESSION_ID]
?.backgroundAgentTasks?.['agent-task-missed-terminal']?.status,
).toBe('completed')
})
it('does not let an older persisted terminal overwrite a new lifecycle with the same Agent id', async () => {
vi.mocked(sessionsApi.getMessages).mockClear()
vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({
+43 -1
View File
@@ -2756,9 +2756,23 @@ export const useChatStore = create<ChatStore>((set, get) => ({
break
case 'session_state': {
const session = get().sessions[sessionId]
let session = get().sessions[sessionId]
if (!session) break
let settledInactiveBackgroundTasks = false
if (msg.activeBackgroundTaskIds !== undefined) {
const reconciled = reconcileBackgroundAgentTasksWithActiveSnapshot(
session.backgroundAgentTasks ?? {},
msg.activeBackgroundTaskIds,
Date.now(),
)
settledInactiveBackgroundTasks = reconciled.changed
if (reconciled.changed) {
update(() => ({ backgroundAgentTasks: reconciled.tasks }))
session = { ...session, backgroundAgentTasks: reconciled.tasks }
}
}
if (msg.turnState === 'running') {
// Raw deltas are not replayable across a socket gap. Discard the
// uncommitted attempt instead of appending new deltas (or a missed
@@ -2807,7 +2821,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}
if (session.chatState === 'idle') {
if (settledInactiveBackgroundTasks) {
useTabStore.getState().updateTabStatus(
sessionId,
hasRunningBackgroundTasks(session.backgroundAgentTasks) ? 'running' : 'idle',
)
}
if (
settledInactiveBackgroundTasks ||
hasRunningSubagentTasks(session.backgroundAgentTasks) ||
session.stopAllSubagentsRequested
) {
@@ -4523,6 +4544,27 @@ function upsertBackgroundAgentTask(
}
}
function reconcileBackgroundAgentTasksWithActiveSnapshot(
current: Record<string, BackgroundAgentTask>,
activeTaskIds: string[],
now: number,
): { tasks: Record<string, BackgroundAgentTask>; changed: boolean } {
const active = new Set(activeTaskIds)
let changed = false
const tasks = Object.fromEntries(
Object.entries(current).map(([key, task]) => {
const remainsActive = active.has(task.taskId) ||
Boolean(task.toolUseId && active.has(task.toolUseId))
if (task.status !== 'running' || remainsActive) return [key, task]
changed = true
// The snapshot proves only that the task settled. History reconciliation
// below replaces this fallback with failed/stopped when that bookend exists.
return [key, { ...task, status: 'completed' as const, updatedAt: now }]
}),
)
return { tasks: changed ? tasks : current, changed }
}
function hasTerminalTaskPayloadChanged(
existing: BackgroundAgentTask,
event: Partial<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
+5 -1
View File
@@ -83,7 +83,11 @@ export type UIAttachment = {
export type ServerMessage =
| { type: 'connected'; sessionId: string }
| { type: 'session_state'; turnState: 'running' | 'idle' }
| {
type: 'session_state'
turnState: 'running' | 'idle'
activeBackgroundTaskIds?: string[]
}
| {
type: 'agent_run_event'
runAgentId: string
@@ -5936,6 +5936,7 @@ describe('WebSocket Chat Integration', () => {
expect(reconnectMessages).toContainEqual({
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
expect(reconnectMessages.some((message) => message.type === 'message_complete')).toBe(false)
} finally {
+30 -2
View File
@@ -569,7 +569,7 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toEqual([
{ type: 'status', state: 'idle' },
{ type: 'session_state', turnState: 'idle' },
{ type: 'session_state', turnState: 'idle', activeBackgroundTaskIds: [] },
])
})
@@ -659,7 +659,11 @@ describe('WebSocket handler session isolation', () => {
type: 'message_complete',
usage: { input_tokens: 12, output_tokens: 3 },
},
{ type: 'session_state', turnState: 'idle' },
{
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: ['background-task-1'],
},
])
})
@@ -972,6 +976,7 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: ['agent-task-1'],
})
__markActiveTurnForTests(sessionId)
@@ -994,6 +999,24 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'running',
activeBackgroundTaskIds: ['agent-task-1'],
})
outputCallbacks[0]?.({
type: 'system',
subtype: 'task_notification',
task_id: 'agent-task-1',
tool_use_id: 'agent-tool-1',
status: 'completed',
summary: 'Playwright checks passed',
task_type: 'local_agent',
})
ws.sent.length = 0
handleWebSocket.message(ws, JSON.stringify({ type: 'sync_state' }))
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'running',
activeBackgroundTaskIds: [],
})
})
@@ -2948,6 +2971,7 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
})
@@ -4471,6 +4495,7 @@ describe('WebSocket handler session isolation', () => {
expect(runningSocket.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'running',
activeBackgroundTaskIds: [],
})
const idleSessionId = `sync-idle-${crypto.randomUUID()}`
@@ -4482,6 +4507,7 @@ describe('WebSocket handler session isolation', () => {
expect(idleSocket.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
})
@@ -4515,6 +4541,7 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'idle',
activeBackgroundTaskIds: [],
})
})
@@ -4558,6 +4585,7 @@ describe('WebSocket handler session isolation', () => {
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
type: 'session_state',
turnState: 'running',
activeBackgroundTaskIds: [],
})
})
})
+5 -1
View File
@@ -57,7 +57,11 @@ export const RUNTIME_CONFIG_APPLIED_EVENT = 'runtime_config_applied' as const
export type ServerMessage =
| { type: 'connected'; sessionId: string }
| { type: 'session_state'; turnState: 'running' | 'idle' }
| {
type: 'session_state'
turnState: 'running' | 'idle'
activeBackgroundTaskIds?: string[]
}
| {
type: 'agent_run_event'
runAgentId: string
+3
View File
@@ -676,6 +676,9 @@ export const handleWebSocket = {
turnState: hasLiveUserTurnForClient(ws.data.sessionId)
? 'running'
: 'idle',
activeBackgroundTaskIds: [
...(activeBackgroundTaskIds.get(ws.data.sessionId) ?? []),
],
})
break