From 45b6674726606a5baa4af756f4ddf7bb5728adb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 21:38:52 +0800 Subject: [PATCH] Place goal background work in the transcript Goal runs spawn visible background work while the conversation is active, so the desktop should show those events at their actual transcript position instead of pinning a separate panel above the chat. The UI now renders background task records as inline message events, repairs restored task state from transcript notifications, and keeps later task updates on the original event card. Constraint: Existing transcripts only persist terminal task notifications, so the server now preserves their timestamps for deterministic restore ordering. Rejected: Keep the page-level background-agent panel | it occluded the session content and duplicated message-flow information. Confidence: high Scope-risk: moderate Directive: Background task message timestamps intentionally stay fixed after insertion; update the task content without moving the card in the transcript. Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts src/pages/ActiveSession.test.tsx src/components/chat/MessageList.test.tsx Tested: cd desktop && bun run lint Tested: bun test src/server/__tests__/sessions.test.ts --- .../src/components/chat/MessageList.test.tsx | 51 +++++ desktop/src/components/chat/MessageList.tsx | 66 ++++++- desktop/src/pages/ActiveSession.test.tsx | 19 +- desktop/src/pages/ActiveSession.tsx | 187 +----------------- desktop/src/stores/chatStore.test.ts | 130 ++++++++++++ desktop/src/stores/chatStore.ts | 172 ++++++++++++---- desktop/src/types/chat.ts | 2 + src/server/__tests__/sessions.test.ts | 2 + src/server/services/sessionService.ts | 12 +- 9 files changed, 405 insertions(+), 236 deletions(-) diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index f7f8f6ad..e6b16210 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -200,6 +200,57 @@ describe('MessageList nested tool calls', () => { expect(screen.getByText('Budget: 0 / unlimited tokens')).toBeTruthy() }) + it('renders background agent progress inline in the transcript', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'user-1', + type: 'user_text', + content: 'run review', + timestamp: 1, + }, + { + id: 'background-task-agent-1', + type: 'background_task', + timestamp: 2, + task: { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'running', + taskType: 'local_agent', + summary: 'Running Playwright checks', + usage: { + totalTokens: 1200, + toolUses: 4, + durationMs: 45000, + }, + startedAt: 2, + updatedAt: 2, + }, + }, + { + id: 'assistant-1', + type: 'assistant_text', + content: 'continuing', + timestamp: 3, + }, + ], + }), + }, + }) + + render() + + const card = screen.getByTestId('background-task-event-card') + expect(card.textContent).toContain('local_agent') + expect(card.textContent).toContain('running') + expect(card.textContent).toContain('Running Playwright checks') + expect(card.textContent).toContain('1,200 tokens') + expect(card.textContent).toContain('45s') + }) + it('restores the full transcript when scrolling away from latest', async () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index edfefaec..35d3335c 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1,5 +1,5 @@ import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react' -import { ArrowDown, BookMarked, ChevronDown, ChevronRight, Settings, Target } from 'lucide-react' +import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, LoaderCircle, Settings, Target, XCircle } from 'lucide-react' import { ApiError } from '../../api/client' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useChatStore } from '../../stores/chatStore' @@ -27,6 +27,7 @@ type ToolCall = Extract type ToolResult = Extract type MemoryEvent = Extract type GoalEvent = Extract +type BackgroundTaskEvent = Extract type RenderItem = | { kind: 'tool_group'; toolCalls: ToolCall[]; id: string } @@ -220,6 +221,66 @@ function GoalEventCard({ message }: { message: GoalEvent }) { ) } +function formatBackgroundTaskDuration(durationMs?: number) { + if (typeof durationMs !== 'number' || durationMs < 0) return null + const seconds = Math.round(durationMs / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + return `${minutes}m ${seconds % 60}s` +} + +function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) { + const t = useTranslation() + const { task } = message + const isRunning = task.status === 'running' + const isFailed = task.status === 'failed' || task.status === 'stopped' + const duration = formatBackgroundTaskDuration(task.usage?.durationMs) + const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId + + return ( +
+
+ + {isRunning ? ( + +
+
+
+
+ {detail} +
+
+
+
+ ) +} + function SelectableChatMessage({ sessionId, messageId, @@ -354,6 +415,7 @@ function isTurnResponseMessage(message: UIMessage) { message.type === 'assistant_text' || message.type === 'tool_use' || message.type === 'tool_result' || + message.type === 'background_task' || message.type === 'error' || message.type === 'task_summary' ) @@ -1124,6 +1186,8 @@ export const MessageBlock = memo(function MessageBlock({ return case 'goal_event': return + case 'background_task': + return case 'system': return (
diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index a62803a1..446225e4 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -135,7 +135,7 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('chat-input')).toHaveAttribute('data-variant', 'default') }) - it('shows the current goal as a persistent session status panel', () => { + it('does not duplicate the current goal as a page-level status panel', () => { const sessionId = 'goal-visible-session' useSessionStore.setState({ @@ -199,15 +199,11 @@ describe('ActiveSession task polling', () => { render() - const panel = screen.getByTestId('active-goal-panel') - expect(panel).toHaveTextContent('当前目标') - expect(panel).toHaveTextContent('自循环运行中') - expect(panel).toHaveTextContent('ship the smoke test') - expect(panel).toHaveTextContent('预算 0 / 2,000 tokens') - expect(panel).toHaveTextContent('续作次数 0') + expect(screen.queryByTestId('active-goal-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('message-list')).toBeInTheDocument() }) - it('shows background agent progress below the goal panel', () => { + it('does not render background agent progress as a page-level panel', () => { const sessionId = 'background-agent-visible-session' useSessionStore.setState({ @@ -277,11 +273,8 @@ describe('ActiveSession task polling', () => { render() - const panel = screen.getByTestId('background-agent-panel') - expect(panel).toHaveTextContent('后台 Agent') - expect(panel).toHaveTextContent('运行中') - expect(panel).toHaveTextContent('local_agent') - expect(panel).toHaveTextContent('Running Playwright checks') + expect(screen.queryByTestId('background-agent-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('message-list')).toBeInTheDocument() }) it('refreshes CLI tasks repeatedly while a turn is active', async () => { diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index eac16e60..f323533c 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -28,8 +28,6 @@ import { TerminalSettings } from './TerminalSettings' import type { SessionListItem } from '../types/session' import { useMobileViewport } from '../hooks/useMobileViewport' import { isTauriRuntime } from '../lib/desktopRuntime' -import type { ActiveGoalState, BackgroundAgentTask } from '../types/chat' -import { Bot, CheckCircle2, LoaderCircle, Target, XCircle } from 'lucide-react' const TASK_POLL_INTERVAL_MS = 1000 const WORKSPACE_RESIZE_STEP = 32 @@ -201,165 +199,6 @@ function TerminalResizeHandle() { ) } -function GoalStatusPanel({ - goal, - isRunning, - compact, -}: { - goal: ActiveGoalState - isRunning: boolean - compact: boolean -}) { - const t = useTranslation() - const stateLabel = goal.action === 'completed' - ? t('chat.activeGoal.completed') - : goal.action === 'paused' || goal.status === 'paused' - ? t('chat.activeGoal.paused') - : isRunning && goal.status !== 'complete' - ? t('chat.activeGoal.running') - : t('chat.activeGoal.active') - const hasMeta = Boolean(goal.budget || goal.continuations || goal.elapsed) - - return ( -
-
- - - - {t('chat.activeGoal.title')} - - - - {goal.objective && ( - - {goal.objective} - - )} - {hasMeta && ( -
- {goal.budget && ( - - {t('chat.activeGoal.budget', { value: goal.budget })} - - )} - {goal.continuations && ( - - {t('chat.activeGoal.continuations', { value: goal.continuations })} - - )} - {goal.elapsed && ( - - {t('chat.activeGoal.elapsed', { value: goal.elapsed })} - - )} -
- )} - {hasMeta && goal.budget ? ( - - {t('chat.activeGoal.budget', { value: goal.budget })} - - ) : null} -
-
- ) -} - -function formatBackgroundTaskDuration(durationMs?: number) { - if (typeof durationMs !== 'number' || durationMs < 0) return null - const seconds = Math.round(durationMs / 1000) - if (seconds < 60) return `${seconds}s` - const minutes = Math.floor(seconds / 60) - return `${minutes}m ${seconds % 60}s` -} - -function BackgroundAgentTasksPanel({ - tasks, - compact, -}: { - tasks: BackgroundAgentTask[] - compact: boolean -}) { - const t = useTranslation() - if (tasks.length === 0) return null - - return ( -
-
-
-
-
- {tasks.map((task) => { - const isRunning = task.status === 'running' - const isFailed = task.status === 'failed' || task.status === 'stopped' - const duration = formatBackgroundTaskDuration(task.usage?.durationMs) - const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId - return ( -
- - {isRunning ? ( - -
-
- - {task.taskType || t('chat.backgroundAgents.agent')} - - - {t(`chat.backgroundAgents.status.${task.status}`)} - - {task.usage?.totalTokens ? ( - - {t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })} - - ) : null} - {duration ? ( - - {duration} - - ) : null} -
-
- {detail} -
-
-
- ) - })} -
-
-
- ) -} - export function ActiveSession() { const isMobileLayout = useMobileViewport() && !isTauriRuntime() const activeTabId = useTabStore((s) => s.activeTabId) @@ -372,16 +211,6 @@ export function ActiveSession() { const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId) const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed')) const chatState = sessionState?.chatState ?? 'idle' - const activeGoal = sessionState?.activeGoal ?? null - const backgroundAgentTasks = useMemo( - () => Object.values(sessionState?.backgroundAgentTasks ?? {}) - .sort((a, b) => { - if (a.status === 'running' && b.status !== 'running') return -1 - if (a.status !== 'running' && b.status === 'running') return 1 - return b.updatedAt - a.updatedAt - }), - [sessionState?.backgroundAgentTasks], - ) const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } const session = sessions.find((s) => s.id === activeTabId) @@ -534,10 +363,10 @@ export function ActiveSession() { className={ showWorkspacePanel ? 'flex w-full items-center border-b border-[var(--color-border)]/70 px-4 py-3' - : 'mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3' + : 'w-full border-b border-outline-variant/10 px-4 py-3' } > -
+

)} - {activeGoal && ( - - )} - - )} diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 80046372..91769291 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -373,6 +373,94 @@ describe('chatStore history mapping', () => { }) }) + it('uses transcript terminal events to repair stale live goal and background task state', async () => { + vi.mocked(sessionsApi.getMessages).mockResolvedValueOnce({ + messages: [ + { + id: 'goal-command', + type: 'system', + timestamp: '2026-04-06T00:00:00.000Z', + content: '/goal\nship the smoke test', + }, + { + id: 'goal-output', + type: 'system', + timestamp: '2026-04-06T00:00:01.000Z', + content: 'Goal set: ship the smoke test', + }, + { + id: 'goal-complete', + type: 'system', + timestamp: '2026-04-06T00:00:02.000Z', + content: 'Goal marked complete.', + }, + ], + taskNotifications: [ + { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'completed', + summary: 'Agent completed', + timestamp: '2026-04-06T00:00:03.000Z', + }, + ], + }) + + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + messages: [{ id: 'visible-message', type: 'assistant_text', content: 'already rendered', timestamp: 1 }], + activeGoal: { + action: 'created', + status: 'active', + objective: 'ship the smoke test', + updatedAt: 1, + }, + backgroundAgentTasks: { + 'agent-tool-1': { + taskId: 'agent-tool-1', + toolUseId: 'agent-tool-1', + status: 'running', + taskType: 'local_agent', + description: 'Review app', + startedAt: 1, + updatedAt: 2, + }, + }, + }), + }, + }) + + await useChatStore.getState().loadHistory(TEST_SESSION_ID) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.messages).toMatchObject([ + { id: 'visible-message', type: 'assistant_text', content: 'already rendered' }, + { + type: 'background_task', + task: { + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'completed', + summary: 'Agent completed', + }, + }, + ]) + expect(session?.activeGoal).toMatchObject({ + action: 'completed', + status: 'complete', + objective: 'ship the smoke test', + }) + expect(session?.backgroundAgentTasks?.['agent-tool-1']).toBeUndefined() + expect(session?.backgroundAgentTasks?.['agent-task-1']).toMatchObject({ + taskId: 'agent-task-1', + toolUseId: 'agent-tool-1', + status: 'completed', + description: 'Review app', + summary: 'Agent completed', + }) + }) + it('merges consecutive assistant text blocks when restoring transcript history', () => { const messages: MessageEntry[] = [ { @@ -1129,6 +1217,9 @@ describe('chatStore history mapping', () => { }) it('tracks background agent task lifecycle events for desktop visibility', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-06T00:00:01.000Z')) + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession({ @@ -1137,6 +1228,8 @@ describe('chatStore history mapping', () => { }, }) + vi.setSystemTime(new Date('2026-04-06T00:00:02.000Z')) + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', subtype: 'task_started', @@ -1157,6 +1250,20 @@ describe('chatStore history mapping', () => { taskType: 'local_agent', prompt: 'Run E2E verification', }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + type: 'background_task', + task: { + taskId: 'agent-task-1', + status: 'running', + description: 'Verify the todo app', + }, + }, + ]) + const insertedTaskTimestamp = useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp + expect(insertedTaskTimestamp).toBe(new Date('2026-04-06T00:00:02.000Z').getTime()) + + vi.setSystemTime(new Date('2026-04-06T00:00:03.000Z')) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', @@ -1185,6 +1292,18 @@ describe('chatStore history mapping', () => { durationMs: 45000, }, }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({ + type: 'background_task', + task: { + taskId: 'agent-task-1', + status: 'running', + summary: 'Running Playwright checks', + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp) + + vi.setSystemTime(new Date('2026-04-06T00:00:04.000Z')) useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { type: 'system_notification', @@ -1218,6 +1337,17 @@ describe('chatStore history mapping', () => { summary: 'Found and fixed localStorage corruption.', outputFile: '/tmp/agent-output.txt', }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toHaveLength(1) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]).toMatchObject({ + type: 'background_task', + task: { + taskId: 'agent-task-1', + status: 'completed', + summary: 'Found and fixed localStorage corruption.', + }, + }) + expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages[0]?.timestamp).toBe(insertedTaskTimestamp) + vi.useRealTimers() }) it('clears local desktop chat state when the server confirms /clear', () => { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 153e661e..8f89eb6d 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -219,6 +219,41 @@ function appendAssistantTextMessage( ] } +function upsertBackgroundTaskMessage( + messages: UIMessage[], + task: BackgroundAgentTask, + timestamp: number, +): UIMessage[] { + const existingIndex = messages.findIndex((message) => + message.type === 'background_task' && + (message.task.taskId === task.taskId || + (task.toolUseId && message.task.toolUseId === task.toolUseId))) + if (existingIndex === -1) { + return [...messages, { + id: `background-task-${task.taskId}`, + type: 'background_task', + task, + timestamp, + }] + } + + return messages.map((message, index) => + index === existingIndex && message.type === 'background_task' + ? { ...message, task: { ...message.task, ...task }, timestamp: message.timestamp || timestamp } + : message) +} + +function mergeBackgroundTaskMessages( + messages: UIMessage[], + tasks: Record, +): UIMessage[] { + const merged = Object.values(tasks).reduce( + (current, task) => upsertBackgroundTaskMessage(current, task, task.updatedAt), + messages, + ) + return [...merged].sort((a, b) => a.timestamp - b.timestamp) +} + function normalizeMemoryEventFiles(data: unknown): MemoryEventFile[] { if (!data || typeof data !== 'object') return [] const writtenPaths = (data as { writtenPaths?: unknown }).writtenPaths @@ -278,14 +313,16 @@ function updateSessionIn( async function fetchAndMapSessionHistory(sessionId: string) { const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) + const restoredNotifications = { + ...reconstructAgentNotifications(messages), + ...agentNotificationRecordFromList(taskNotifications ?? []), + } return { rawMessages: messages, uiMessages, activeGoal: deriveActiveGoalFromMessages(uiMessages), - restoredNotifications: { - ...reconstructAgentNotifications(messages), - ...agentNotificationRecordFromList(taskNotifications ?? []), - }, + restoredNotifications, + restoredBackgroundTasks: backgroundTaskRecordFromNotifications(Object.values(restoredNotifications)), lastTodos: extractLastTodoWriteFromHistory(messages), hasMessagesAfterTaskCompletion: hasUserMessagesAfterTaskCompletion(messages), } @@ -540,16 +577,32 @@ export const useChatStore = create((set, get) => ({ uiMessages, activeGoal, restoredNotifications, + restoredBackgroundTasks, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) set((state) => { const session = state.sessions[sessionId] - if (!session || session.messages.length > 0) return state + if (!session) return state + if (session.messages.length > 0) { + return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ + activeGoal: activeGoal ?? s.activeGoal ?? null, + agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, + backgroundAgentTasks: mergeBackgroundAgentTaskRecords( + s.backgroundAgentTasks ?? {}, + restoredBackgroundTasks, + ), + messages: mergeBackgroundTaskMessages(s.messages, restoredBackgroundTasks), + })) } + } return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ - messages: uiMessages, + messages: mergeBackgroundTaskMessages(uiMessages, restoredBackgroundTasks), activeGoal, agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, + backgroundAgentTasks: mergeBackgroundAgentTaskRecords( + s.backgroundAgentTasks ?? {}, + restoredBackgroundTasks, + ), })) } }) if (lastTodos && lastTodos.length > 0) { @@ -572,6 +625,7 @@ export const useChatStore = create((set, get) => ({ uiMessages, activeGoal, restoredNotifications, + restoredBackgroundTasks, lastTodos, hasMessagesAfterTaskCompletion, } = await fetchAndMapSessionHistory(sessionId) @@ -582,9 +636,10 @@ export const useChatStore = create((set, get) => ({ if (session.elapsedTimer) clearInterval(session.elapsedTimer) return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ - messages: uiMessages, + messages: mergeBackgroundTaskMessages(uiMessages, restoredBackgroundTasks), activeGoal, agentTaskNotifications: restoredNotifications, + backgroundAgentTasks: restoredBackgroundTasks, chatState: 'idle', activeThinkingId: null, activeToolUseId: null, @@ -998,13 +1053,18 @@ export const useChatStore = create((set, get) => ({ const taskEvent = normalizeBackgroundAgentTaskEvent(msg.data, msg.subtype) if (taskEvent) { const now = Date.now() - update((session) => ({ - backgroundAgentTasks: upsertBackgroundAgentTask( + update((session) => { + const backgroundAgentTasks = upsertBackgroundAgentTask( session.backgroundAgentTasks ?? {}, taskEvent, now, - ), - })) + ) + const task = backgroundAgentTasks[taskEvent.taskId] + return { + backgroundAgentTasks, + ...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}), + } + }) } } if (msg.subtype === 'task_notification' && msg.data && typeof msg.data === 'object') { @@ -1017,31 +1077,36 @@ export const useChatStore = create((set, get) => ({ const taskStatus = data.status if (taskEvent) { const now = Date.now() - update((session) => ({ - backgroundAgentTasks: upsertBackgroundAgentTask( + update((session) => { + const backgroundAgentTasks = upsertBackgroundAgentTask( session.backgroundAgentTasks ?? {}, taskEvent, now, - ), - agentTaskNotifications: { - ...session.agentTaskNotifications, - ...(toolUseId && - (taskStatus === 'completed' || - taskStatus === 'failed' || - taskStatus === 'stopped') - ? { - [toolUseId]: { - taskId: taskEvent.taskId, - toolUseId, - status: taskStatus, - summary: taskEvent.summary, - outputFile: taskEvent.outputFile, - usage: taskEvent.usage, - }, - } - : {}), - }, - })) + ) + const task = backgroundAgentTasks[taskEvent.taskId] + return { + backgroundAgentTasks, + ...(task ? { messages: upsertBackgroundTaskMessage(session.messages, task, now) } : {}), + agentTaskNotifications: { + ...session.agentTaskNotifications, + ...(toolUseId && + (taskStatus === 'completed' || + taskStatus === 'failed' || + taskStatus === 'stopped') + ? { + [toolUseId]: { + taskId: taskEvent.taskId, + toolUseId, + status: taskStatus, + summary: taskEvent.summary, + outputFile: taskEvent.outputFile, + usage: taskEvent.usage, + }, + } + : {}), + }, + } + }) } } break @@ -1190,9 +1255,19 @@ function upsertBackgroundAgentTask( event: Partial & Pick, now: number, ): Record { - const existing = current[event.taskId] + const existingKey = current[event.taskId] + ? event.taskId + : event.toolUseId + ? Object.keys(current).find((key) => + key === event.toolUseId || current[key]?.toolUseId === event.toolUseId) + : undefined + const existing = existingKey ? current[existingKey] : undefined + const next = { ...current } + if (existingKey && existingKey !== event.taskId) { + delete next[existingKey] + } return { - ...current, + ...next, [event.taskId]: { taskId: event.taskId, toolUseId: event.toolUseId ?? existing?.toolUseId, @@ -1361,6 +1436,33 @@ function agentNotificationRecordFromList( ) } +function backgroundTaskRecordFromNotifications( + notifications: AgentTaskNotification[], +): Record { + return notifications.reduce>((tasks, notification) => { + const parsedTimestamp = notification.timestamp ? new Date(notification.timestamp).getTime() : NaN + const now = Number.isFinite(parsedTimestamp) ? parsedTimestamp : Date.now() + return upsertBackgroundAgentTask(tasks, { + taskId: notification.taskId, + toolUseId: notification.toolUseId, + status: notification.status, + summary: notification.summary, + outputFile: notification.outputFile, + usage: notification.usage, + }, now) + }, {}) +} + +function mergeBackgroundAgentTaskRecords( + current: Record, + restored: Record, +): Record { + return Object.values(restored).reduce( + (tasks, task) => upsertBackgroundAgentTask(tasks, task, task.updatedAt), + current, + ) +} + const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g function extractVisibleTeammateMessageContents(text: string): string[] { diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 947503e8..cb8b24b6 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -157,6 +157,7 @@ export type AgentTaskNotification = { summary?: string outputFile?: string usage?: BackgroundAgentTaskUsage + timestamp?: string } export type BackgroundAgentTaskUsage = { @@ -215,6 +216,7 @@ export type UIMessage = | { id: string; type: 'thinking'; content: string; timestamp: number } | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string } | { id: string; type: 'tool_result'; toolUseId: string; content: unknown; isError: boolean; timestamp: number; parentToolUseId?: string } + | { id: string; type: 'background_task'; task: BackgroundAgentTask; timestamp: number } | { id: string; type: 'system'; content: string; timestamp: number } | { id: string diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 866e86bb..ffd0eee9 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -873,6 +873,7 @@ describe('SessionService', () => { toolUseId: 'toolu_bg', status: 'completed', summary: 'Background command completed', + timestamp: '2026-01-01T00:01:00.000Z', }, ]) }) @@ -1706,6 +1707,7 @@ describe('Sessions API', () => { status: 'failed', summary: 'Background command failed & stopped', outputFile: 'C:\\Temp\\bg.output', + timestamp: expect.any(String), }, ]) }) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index e617b888..0e651d75 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -90,6 +90,7 @@ export type SessionTaskNotification = { status: 'completed' | 'failed' | 'stopped' summary?: string outputFile?: string + timestamp?: string } export type TranscriptUsageSnapshot = { @@ -512,7 +513,10 @@ export class SessionService { return match?.[1] ? this.decodeXmlText(match[1].trim()) : undefined } - private parseTaskNotificationContent(content: unknown): SessionTaskNotification | null { + private parseTaskNotificationContent( + content: unknown, + timestamp?: string, + ): SessionTaskNotification | null { const xml = this.extractTextBlocks(content) .map((text) => this.extractTaskNotificationXml(text)) .find((value): value is string => value !== null) @@ -536,6 +540,7 @@ export class SessionService { status, ...(summary ? { summary } : {}), ...(outputFile ? { outputFile } : {}), + ...(timestamp ? { timestamp } : {}), } } @@ -1835,7 +1840,10 @@ export class SessionService { const notifications: SessionTaskNotification[] = [] for (const entry of entries) { if (entry.message?.role !== 'user') continue - const notification = this.parseTaskNotificationContent(entry.message.content) + const notification = this.parseTaskNotificationContent( + entry.message.content, + entry.timestamp, + ) if (notification) notifications.push(notification) } return notifications