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