From 76351da2a16d077aa7cda0f5afd7c8dbc22efa44 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 20:12:28 -0700 Subject: [PATCH] Show running Claude background task details --- .../claude-background-task-tracker.test.ts | 80 +++++++++++-- .../claude/claude-background-task-tracker.ts | 77 +++++++++--- .../claude-structured-session-close.test.ts | 9 +- ...ructured-agent-session-subscribers.test.ts | 8 +- .../NativeChatBackgroundTasksStatus.tsx | 111 ++++++++++++++---- .../NativeChatStructuredSession.test.tsx | 18 ++- .../NativeChatStructuredSession.tsx | 1 + .../use-structured-agent-session.ts | 1 + src/shared/agent-session-wire.ts | 12 +- ...structured-agent-session-coalescer.test.ts | 14 ++- .../structured-agent-session-reducer.test.ts | 40 +++++++ .../structured-agent-session-reducer.ts | 28 ++++- 12 files changed, 343 insertions(+), 56 deletions(-) diff --git a/src/main/claude/claude-background-task-tracker.test.ts b/src/main/claude/claude-background-task-tracker.test.ts index 20cda8a8099..ea195f10bc4 100644 --- a/src/main/claude/claude-background-task-tracker.test.ts +++ b/src/main/claude/claude-background-task-tracker.test.ts @@ -38,7 +38,10 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toBeNull() expect(tracker.observe(result())).toBe(true) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'agent' }] + }) }) it('uses an explicit background update for a foreground task and ignores progress alone', () => { @@ -56,7 +59,46 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toBeNull() tracker.observe(system('task_updated', { task_id: 'task-1', patch: { is_backgrounded: true } })) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'command' }] + }) + }) + + it('publishes bounded display details when a running task description changes', () => { + const tracker = new ClaudeBackgroundTaskTracker() + expect( + tracker.observe( + system('task_started', { + task_id: 'task-1', + task_type: 'local_bash', + is_backgrounded: true, + description: ' run\n the build ' + }) + ) + ).toBe(true) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }] + }) + + expect( + tracker.observe( + system('task_updated', { + task_id: 'task-1', + patch: { description: 'x'.repeat(600) } + }) + ) + ).toBe(true) + expect(tracker.state?.tasks?.[0]?.description).toHaveLength(512) + expect( + tracker.observe( + system('task_updated', { + task_id: 'task-1', + patch: { description: 'x'.repeat(600) } + }) + ) + ).toBe(false) }) it('replaces its roster from aggregate lifecycle frames and preserves stoppable provider ids', () => { @@ -70,14 +112,24 @@ describe('ClaudeBackgroundTaskTracker', () => { ) ).toBe(true) expect(tracker.stoppableTaskIds).toEqual(['task-agent', 'task-bash']) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [ + { id: 'task-agent', kind: 'agent', description: 'agent' }, + { id: 'task-bash', kind: 'command', description: 'bash' } + ] + }) expect( tracker.observe( aggregate([{ task_id: 'task-next', task_type: 'local_workflow', description: 'workflow' }]) ) - ).toBe(false) + ).toBe(true) expect(tracker.stoppableTaskIds).toEqual(['task-next']) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-next', kind: 'workflow', description: 'workflow' }] + }) expect(tracker.observe(aggregate([]))).toBe(true) expect(tracker.stoppableTaskIds).toEqual([]) @@ -127,7 +179,10 @@ describe('ClaudeBackgroundTaskTracker', () => { ) expect(tracker.stoppableTaskIds).toEqual(['task-live']) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-live', kind: 'agent', description: 'agent' }] + }) }) it('keeps terminal edges authoritative on either side of aggregate replacement', () => { @@ -180,7 +235,10 @@ describe('ClaudeBackgroundTaskTracker', () => { task_type: 'monitor' }) ) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-live', kind: 'monitor' }] + }) expect( tracker.observe(system('task_updated', { task_id: 'task-live', patch: { status: 'killed' } })) ).toBe(true) @@ -191,7 +249,10 @@ describe('ClaudeBackgroundTaskTracker', () => { for (const taskType of ['local_workflow', 'monitor']) { const tracker = new ClaudeBackgroundTaskTracker() tracker.observe(system('task_started', { task_id: taskType, task_type: taskType })) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: taskType, kind: taskType === 'local_workflow' ? 'workflow' : 'monitor' }] + }) } }) @@ -247,7 +308,10 @@ describe('ClaudeBackgroundTaskTracker', () => { expect(tracker.state).toBeNull() expect(tracker.observe(result())).toBe(true) - expect(tracker.state).toEqual({ state: 'monitoring' }) + expect(tracker.state).toEqual({ + state: 'monitoring', + tasks: [{ id: 'task-live', kind: 'command', description: 'command' }] + }) }) it('ignores ambient SDK tasks and clears all liveness when the session ends', () => { diff --git a/src/main/claude/claude-background-task-tracker.ts b/src/main/claude/claude-background-task-tracker.ts index 15665099122..5744ee1bdbd 100644 --- a/src/main/claude/claude-background-task-tracker.ts +++ b/src/main/claude/claude-background-task-tracker.ts @@ -1,14 +1,19 @@ -import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' +import type { + AgentSessionBackgroundTask, + AgentSessionBackgroundTaskState +} from '../../shared/agent-session-wire' const MAX_TRACKED_TASKS = 256 const MAX_TASK_ID_LENGTH = 512 +const MAX_TASK_DESCRIPTION_LENGTH = 512 const TERMINAL_TASK_STATES = new Set(['completed', 'failed', 'killed', 'stopped']) -export type ClaudeBackgroundTaskKind = 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown' +export type ClaudeBackgroundTaskKind = AgentSessionBackgroundTask['kind'] type TrackedTask = { backgrounded: boolean kind: ClaudeBackgroundTaskKind + description?: string } function record(value: unknown): Record | null { @@ -22,6 +27,14 @@ function taskId(message: Record): string | null { : null } +function taskDescription(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim().replace(/\s+/g, ' ') + return trimmed.length > 0 ? trimmed.slice(0, MAX_TASK_DESCRIPTION_LENGTH) : undefined +} + export function classifyClaudeBackgroundTaskKind(taskType: unknown): ClaudeBackgroundTaskKind { switch (taskType) { case 'local_agent': @@ -43,9 +56,16 @@ export class ClaudeBackgroundTaskTracker { private aggregateRosterObserved = false private foregroundTurnActive = false private monitoring = false + private publishedTasksFingerprint = '' get state(): AgentSessionBackgroundTaskState | null { - return this.monitoring ? { state: 'monitoring' } : null + if (!this.monitoring) { + return null + } + return { + state: 'monitoring', + tasks: this.backgroundTaskDetails() + } } get stoppableTaskIds(): string[] { @@ -100,8 +120,16 @@ export class ClaudeBackgroundTaskTracker { this.finish(id) return } - if (patch.is_backgrounded === true && (!this.aggregateRosterObserved || this.tasks.has(id))) { - this.upsert(id, { backgrounded: true, kind: this.tasks.get(id)?.kind ?? 'unknown' }) + const existing = this.tasks.get(id) + if ( + (patch.is_backgrounded === true || taskDescription(patch.description)) && + (!this.aggregateRosterObserved || existing) + ) { + this.upsert(id, { + backgrounded: patch.is_backgrounded === true || existing?.backgrounded === true, + kind: existing?.kind ?? 'unknown', + description: taskDescription(patch.description) ?? existing?.description + }) } return } @@ -118,7 +146,8 @@ export class ClaudeBackgroundTaskTracker { const kind = classifyClaudeBackgroundTaskKind(message.task_type) this.upsert(id, { backgrounded: message.is_backgrounded === true || kind === 'workflow' || kind === 'monitor', - kind + kind, + description: taskDescription(message.description) }) } @@ -143,7 +172,8 @@ export class ClaudeBackgroundTaskTracker { } this.tasks.set(id, { backgrounded: true, - kind: classifyClaudeBackgroundTaskKind(task.task_type) + kind: classifyClaudeBackgroundTaskKind(task.task_type), + description: taskDescription(task.description) }) } } @@ -153,7 +183,8 @@ export class ClaudeBackgroundTaskTracker { if (existing) { this.tasks.set(id, { backgrounded: existing.backgrounded || task.backgrounded, - kind: existing.kind === 'unknown' ? task.kind : existing.kind + kind: existing.kind === 'unknown' ? task.kind : existing.kind, + description: task.description ?? existing.description }) return } @@ -186,19 +217,29 @@ export class ClaudeBackgroundTaskTracker { } private refreshMonitoring(): boolean { - let next = false - if (!this.foregroundTurnActive) { - for (const task of this.tasks.values()) { - if (task.backgrounded) { - next = true - break - } - } - } - if (next === this.monitoring) { + const details = this.foregroundTurnActive ? [] : this.backgroundTaskDetails() + const next = details.length > 0 + const fingerprint = next ? JSON.stringify(details) : '' + if (next === this.monitoring && fingerprint === this.publishedTasksFingerprint) { return false } this.monitoring = next + this.publishedTasksFingerprint = fingerprint return true } + + private backgroundTaskDetails(): AgentSessionBackgroundTask[] { + const details: AgentSessionBackgroundTask[] = [] + for (const [id, task] of this.tasks) { + if (!task.backgrounded) { + continue + } + details.push({ + id, + kind: task.kind, + ...(task.description ? { description: task.description } : {}) + }) + } + return details + } } diff --git a/src/main/claude/claude-structured-session-close.test.ts b/src/main/claude/claude-structured-session-close.test.ts index ee8e80346c7..0df0e913e50 100644 --- a/src/main/claude/claude-structured-session-close.test.ts +++ b/src/main/claude/claude-structured-session-close.test.ts @@ -52,7 +52,9 @@ describe('Claude published session close lifecycle', () => { task_type: 'local_agent', is_backgrounded: true }) - expect(backgroundStates).toEqual([{ state: 'monitoring' }]) + expect(backgroundStates).toEqual([ + { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] } + ]) const session = ( adapter as unknown as { sessions: Map void } | null }> @@ -65,7 +67,10 @@ describe('Claude published session close lifecycle', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) expect(events.filter((event) => event.type === 'handle')).toHaveLength(0) expect(disposeTranslator).toHaveBeenCalledOnce() - expect(backgroundStates).toEqual([{ state: 'monitoring' }, null]) + expect(backgroundStates).toEqual([ + { state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] }, + null + ]) await expect(adapter.closeSession('session-1')).resolves.toBe(true) expect(persistHandle).toHaveBeenCalledTimes(2) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 0878c2156b2..6e156d91532 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -137,7 +137,11 @@ describe('AgentSessionSubscribers', () => { }) const cursor = journal.cursor() - subscribers.backgroundTasks(SESSION, { state: 'monitoring' }, 2) + const backgroundTasks = { + state: 'monitoring' as const, + tasks: [{ id: 'task-1', kind: 'command' as const, description: 'run the build' }] + } + subscribers.backgroundTasks(SESSION, backgroundTasks, 2) expect(journal.cursor()).toEqual(cursor) expect(events.at(-1)).toEqual({ @@ -145,7 +149,7 @@ describe('AgentSessionSubscribers', () => { sessionId: SESSION, batch: { cursor, items: [], removedItemIds: [], submissions: [] }, fence: 2, - backgroundTasks: { state: 'monitoring' } + backgroundTasks }) await journal.appendItem( diff --git a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx index 659ccc7ddbc..5163d5f467b 100644 --- a/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatBackgroundTasksStatus.tsx @@ -1,36 +1,107 @@ +import { useId, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import { AgentStateDot } from '@/components/AgentStateDot' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +function backgroundTaskLabel(task: AgentSessionBackgroundTask): string { + if (task.description) { + return task.description + } + switch (task.kind) { + case 'agent': + return translate('components.native-chat.backgroundTasks.agent', 'Background agent') + case 'workflow': + return translate('components.native-chat.backgroundTasks.workflow', 'Background workflow') + case 'command': + return translate('components.native-chat.backgroundTasks.command', 'Background command') + case 'monitor': + return translate('components.native-chat.backgroundTasks.monitor', 'Background monitor') + case 'unknown': + return translate('components.native-chat.backgroundTasks.task', 'Background task') + } +} + export function NativeChatBackgroundTasksStatus(props: { + tasks: readonly AgentSessionBackgroundTask[] stopping: boolean onStop: () => void }): React.JSX.Element { + const [expanded, setExpanded] = useState(false) + const taskListId = useId() return (
-
- - - {translate( - 'components.native-chat.backgroundTasks.monitoring', - 'Monitoring background tasks' - )} - - +
+
+ + +
+ {expanded ? ( +
+ {props.tasks.length > 0 ? ( +
    + {props.tasks.map((task) => ( +
  • +
  • + ))} +
+ ) : ( +

+ {translate( + 'components.native-chat.backgroundTasks.detailsUnavailable', + 'Task details are unavailable for this session.' + )} +

+ )} +
+ ) : null}
) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 5d6058376f9..afdf5ace7c5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import React, { forwardRef, useImperativeHandle } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire' import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' @@ -28,6 +29,7 @@ const mocks = vi.hoisted(() => ({ pasteFromClipboard: vi.fn(), submissions: [] as unknown[], monitoringBackgroundTasks: false, + backgroundTasks: [] as AgentSessionBackgroundTask[], stopBackgroundTasks: vi.fn() })) @@ -73,6 +75,7 @@ vi.mock('./use-structured-agent-session', async () => { retry: outbox.retry, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, + backgroundTasks: mocks.backgroundTasks, turnId: null, cancel: vi.fn(), stopBackgroundTasks: mocks.stopBackgroundTasks, @@ -164,6 +167,7 @@ describe('NativeChatStructuredSession', () => { mocks.submissions = [] mocks.monitoringBackgroundTasks = false mocks.stopBackgroundTasks.mockReset() + mocks.backgroundTasks = [] }) it('routes app-menu paste into the structured composer', () => { @@ -221,6 +225,10 @@ describe('NativeChatStructuredSession', () => { it('places background monitoring above the usable composer and stops without an active turn', async () => { mocks.monitoringBackgroundTasks = true + mocks.backgroundTasks = [ + { id: 'task-command', kind: 'command', description: 'sleep 180' }, + { id: 'task-agent', kind: 'agent' } + ] mocks.stopBackgroundTasks.mockResolvedValue({ cancelled: true }) render( @@ -230,7 +238,6 @@ describe('NativeChatStructuredSession', () => { sessionId="session-background" target={{ kind: 'local' }} agent="claude" - allowFileUriLinks={false} /> ) @@ -243,6 +250,15 @@ describe('NativeChatStructuredSession', () => { } expect(status.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(mocks.composerProps?.isWorking).toBe(false) + expect(screen.queryByRole('list', { name: 'Running background tasks' })).toBeNull() + + const disclosure = screen.getByRole('button', { name: 'Monitoring background tasks' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('list', { name: 'Running background tasks' })).toBeTruthy() + expect(screen.getByText('sleep 180')).toBeTruthy() + expect(screen.getByText('Background agent')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Stop' })) await waitFor(() => expect(mocks.stopBackgroundTasks).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 46d8cae9bd0..9ac354f8a73 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -276,6 +276,7 @@ export function NativeChatStructuredSession( ) : null} {controller.isMonitoringBackgroundTasks ? ( { setStoppingBackgroundTasks(true) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index a23e73903e0..3ae45bc3f43 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -244,6 +244,7 @@ export function useStructuredAgentSession(args: { retry: outboxController.retry, isWorking: turnId !== null, isMonitoringBackgroundTasks, + backgroundTasks: state.backgroundTasks?.tasks ?? [], turnId, cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), stopBackgroundTasks: () => diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 8558dd9625c..40a408df899 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -49,7 +49,17 @@ export type AgentSessionHandoffRequest = { export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } -export type AgentSessionBackgroundTaskState = { state: 'monitoring' } +export type AgentSessionBackgroundTask = { + id: string + kind: 'agent' | 'workflow' | 'command' | 'monitor' | 'unknown' + description?: string +} + +export type AgentSessionBackgroundTaskState = { + state: 'monitoring' + /** Optional so mixed-version clients can consume state-only hosts. */ + tasks?: AgentSessionBackgroundTask[] +} /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 64682998ae0..770b08308af 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -24,12 +24,22 @@ describe('structured agent session event coalescer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - coalescer.push(batch(1, { state: 'monitoring' })) + coalescer.push( + batch(1, { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }] + }) + ) coalescer.push(batch(2)) coalescer.flush() expect(events).toHaveLength(1) - expect(events[0]).toMatchObject({ backgroundTasks: { state: 'monitoring' } }) + expect(events[0]).toMatchObject({ + backgroundTasks: { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'command', description: 'run the build' }] + } + }) }) it('keeps an explicit terminal state as the newest coalesced value', () => { diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 781b8dcfa81..99ced770641 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -312,6 +312,46 @@ describe('structured agent session reducer', () => { expect(duplicate).toBe(monitoring) }) + it('applies background task roster changes without a journal update', () => { + const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('message', 1)]), + backgroundTasks: { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'command', description: 'first command' }] + } + } + }) + const changed = reduceStructuredAgentSession(monitoring, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: monitoring.cursor!, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 1, + backgroundTasks: { + state: 'monitoring', + tasks: [{ id: 'task-1', kind: 'agent', description: 'review the change' }] + } + } + }) + + expect(changed).not.toBe(monitoring) + expect(changed.backgroundTasks?.tasks).toEqual([ + { id: 'task-1', kind: 'agent', description: 'review the change' } + ]) + expect(changed.items).toBe(monitoring.items) + }) + it('clears additive background state when a replacement snapshot omits the field', () => { const monitoring = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { type: 'event', diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index eba4086226d..0a330539548 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -44,6 +44,30 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { const MAX_RETAINED_SUBMISSIONS = 256 +function backgroundTaskStatesEqual( + left: AgentSessionBackgroundTaskState | null | undefined, + right: AgentSessionBackgroundTaskState | null | undefined +): boolean { + if (left === right) { + return true + } + if (!left || !right || left.state !== right.state) { + return false + } + if (left.tasks === right.tasks) { + return true + } + if (!left.tasks || !right.tasks || left.tasks.length !== right.tasks.length) { + return false + } + return left.tasks.every( + (task, index) => + task.id === right.tasks?.[index]?.id && + task.kind === right.tasks[index]?.kind && + task.description === right.tasks[index]?.description + ) +} + function replacePage( page: AgentSessionHistoryPage, fence: number, @@ -123,7 +147,7 @@ export function reduceStructuredAgentSession( ) { const backgroundTasksChanged = action.page.backgroundTasks !== undefined && - action.page.backgroundTasks?.state !== state.backgroundTasks?.state + !backgroundTaskStatesEqual(action.page.backgroundTasks, state.backgroundTasks) if ( pageCursor?.sequence === state.cursor.sequence && ((action.page.fence !== undefined && action.page.fence !== state.fence) || @@ -195,7 +219,7 @@ export function reduceStructuredAgentSession( journalUnchanged && (event.fence === undefined || event.fence === state.fence) && (event.handoff === undefined || event.handoff === state.handoff) && - backgroundTasks?.state === state.backgroundTasks?.state && + backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) && state.status === 'ready' && state.error === undefined ) {