mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): reconcile stale H5 task state #1214
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -676,6 +676,9 @@ export const handleWebSocket = {
|
||||
turnState: hasLiveUserTurnForClient(ws.data.sessionId)
|
||||
? 'running'
|
||||
: 'idle',
|
||||
activeBackgroundTaskIds: [
|
||||
...(activeBackgroundTaskIds.get(ws.data.sessionId) ?? []),
|
||||
],
|
||||
})
|
||||
break
|
||||
|
||||
|
||||
Reference in New Issue
Block a user