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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 21:38:52 +08:00
parent 3fb3d24911
commit 45b6674726
9 changed files with 405 additions and 236 deletions

View File

@ -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(<MessageList />)
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: {

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
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 (
<div className="mb-2">
<div
data-testid="background-task-event-card"
className="flex min-w-0 items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : (
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<Bot size={14} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
{task.taskType || t('chat.backgroundAgents.agent')}
</span>
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{t(`chat.backgroundAgents.status.${task.status}`)}
</span>
{task.usage?.totalTokens ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })}
</span>
) : null}
{duration ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{duration}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[12px] leading-5 text-[var(--color-text-secondary)]">
{detail}
</div>
</div>
</div>
</div>
)
}
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 <MemoryEventCard message={message} />
case 'goal_event':
return <GoalEventCard message={message} />
case 'background_task':
return <BackgroundTaskEventCard message={message} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">

View File

@ -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(<ActiveSession />)
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(<ActiveSession />)
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 () => {

View File

@ -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 (
<div
data-testid="active-goal-panel"
className={
compact
? 'mx-auto w-full max-w-[860px] px-4 py-2'
: 'mx-auto w-full max-w-[860px] px-8 py-2.5'
}
>
<div className="flex min-w-0 items-center gap-2 overflow-hidden rounded-lg border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)] px-3 py-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-[var(--color-memory-accent)]">
<Target size={15} strokeWidth={2.25} aria-hidden="true" />
</span>
<span className="shrink-0 text-[13px] font-medium text-[var(--color-text-primary)]">
{t('chat.activeGoal.title')}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-[12px] text-[var(--color-text-tertiary)]">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-memory-accent)]" aria-hidden="true" />
{stateLabel}
</span>
{goal.objective && (
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{goal.objective}
</span>
)}
{hasMeta && (
<div className="hidden shrink-0 items-center gap-1.5 md:flex">
{goal.budget && (
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('chat.activeGoal.budget', { value: goal.budget })}
</span>
)}
{goal.continuations && (
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('chat.activeGoal.continuations', { value: goal.continuations })}
</span>
)}
{goal.elapsed && (
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('chat.activeGoal.elapsed', { value: goal.elapsed })}
</span>
)}
</div>
)}
{hasMeta && goal.budget ? (
<span className="ml-auto shrink-0 text-[11px] font-medium text-[var(--color-text-tertiary)] md:hidden">
{t('chat.activeGoal.budget', { value: goal.budget })}
</span>
) : null}
</div>
</div>
)
}
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 (
<div
data-testid="background-agent-panel"
className={
compact
? 'mx-auto w-full max-w-[860px] px-4 py-2'
: 'mx-auto w-full max-w-[860px] px-8 py-2.5'
}
>
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
<Bot size={15} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="shrink-0 text-[13px] font-medium text-[var(--color-text-primary)]">
{t('chat.backgroundAgents.title')}
</span>
<span className="truncate text-[12px] text-[var(--color-text-tertiary)]">
{t('chat.backgroundAgents.count', { count: tasks.length })}
</span>
</div>
<div className="divide-y divide-[var(--color-border)]">
{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 (
<div key={task.taskId} className="flex min-w-0 items-start gap-2 px-3 py-2">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-accent)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : (
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
{task.taskType || t('chat.backgroundAgents.agent')}
</span>
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{t(`chat.backgroundAgents.status.${task.status}`)}
</span>
{task.usage?.totalTokens ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })}
</span>
) : null}
{duration ? (
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{duration}
</span>
) : null}
</div>
<div className="mt-0.5 truncate text-[12px] text-[var(--color-text-secondary)]">
{detail}
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
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'
}
>
<div className="min-w-0 flex-1">
<div className={showWorkspacePanel ? 'min-w-0 flex-1' : 'mx-auto w-full max-w-[860px] min-w-0'}>
<h1
className={
showWorkspacePanel
@ -591,18 +420,6 @@ export function ActiveSession() {
</div>
)}
{activeGoal && (
<GoalStatusPanel
goal={activeGoal}
isRunning={isActive}
compact={showWorkspacePanel || isMobileLayout}
/>
)}
<BackgroundAgentTasksPanel
tasks={backgroundAgentTasks}
compact={showWorkspacePanel || isMobileLayout}
/>
<MessageList compact={showWorkspacePanel} />
</>
)}

View File

@ -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: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
},
{
id: 'goal-output',
type: 'system',
timestamp: '2026-04-06T00:00:01.000Z',
content: '<local-command-stdout>Goal set: ship the smoke test</local-command-stdout>',
},
{
id: 'goal-complete',
type: 'system',
timestamp: '2026-04-06T00:00:02.000Z',
content: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
},
],
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', () => {

View File

@ -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<string, BackgroundAgentTask>,
): 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<ChatStore>((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<ChatStore>((set, get) => ({
uiMessages,
activeGoal,
restoredNotifications,
restoredBackgroundTasks,
lastTodos,
hasMessagesAfterTaskCompletion,
} = await fetchAndMapSessionHistory(sessionId)
@ -582,9 +636,10 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<BackgroundAgentTask> & Pick<BackgroundAgentTask, 'taskId' | 'status'>,
now: number,
): Record<string, BackgroundAgentTask> {
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<string, BackgroundAgentTask> {
return notifications.reduce<Record<string, BackgroundAgentTask>>((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<string, BackgroundAgentTask>,
restored: Record<string, BackgroundAgentTask>,
): Record<string, BackgroundAgentTask> {
return Object.values(restored).reduce(
(tasks, task) => upsertBackgroundAgentTask(tasks, task, task.updatedAt),
current,
)
}
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\n?([\s\S]*?)\n?<\/teammate-message>/g
function extractVisibleTeammateMessageContents(text: string): string[] {

View File

@ -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

View File

@ -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),
},
])
})

View File

@ -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