Show running Claude background task details

This commit is contained in:
Merge Sim
2026-09-04 20:14:09 -07:00
parent b10d7b18ae
commit 76351da2a1
12 changed files with 343 additions and 56 deletions
@@ -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', () => {
@@ -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<string, unknown> | null {
@@ -22,6 +27,14 @@ function taskId(message: Record<string, unknown>): 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
}
}
@@ -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<string, { translator: { dispose: () => 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)
@@ -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(
@@ -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 (
<div
data-native-chat-background-tasks="true"
className="shrink-0 bg-background px-3 pt-2 sm:px-4"
>
<div className="mx-auto flex h-8 w-full max-w-4xl items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 text-xs text-muted-foreground shadow-xs">
<span aria-hidden="true">
<AgentStateDot state="monitoring" size="md" title={null} />
</span>
<span>
{translate(
'components.native-chat.backgroundTasks.monitoring',
'Monitoring background tasks'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
className="ml-auto"
disabled={props.stopping}
onClick={props.onStop}
>
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
</Button>
<div className="mx-auto w-full max-w-4xl overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs">
<div className="flex h-8 items-center gap-1 px-1.5">
<button
type="button"
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-expanded={expanded}
aria-controls={taskListId}
onClick={() => setExpanded((current) => !current)}
>
<span aria-hidden="true">
<AgentStateDot state="monitoring" size="md" title={null} />
</span>
<span className="min-w-0 flex-1 truncate">
{translate(
'components.native-chat.backgroundTasks.monitoring',
'Monitoring background tasks'
)}
</span>
<ChevronDown
aria-hidden="true"
className={`size-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
<Button
type="button"
variant="ghost"
size="xs"
disabled={props.stopping}
onClick={props.onStop}
>
{translate('components.native-chat.backgroundTasks.stop', 'Stop')}
</Button>
</div>
{expanded ? (
<div
id={taskListId}
className="scrollbar-sleek max-h-40 overflow-y-auto border-t border-border px-3 py-2"
>
{props.tasks.length > 0 ? (
<ul
role="list"
aria-label={translate(
'components.native-chat.backgroundTasks.runningList',
'Running background tasks'
)}
className="space-y-1.5"
>
{props.tasks.map((task) => (
<li key={task.id} className="flex min-w-0 items-start gap-2 text-foreground/80">
<span
aria-hidden="true"
className="mt-1 size-1.5 shrink-0 rounded-full bg-primary"
/>
<span className="min-w-0 break-words">{backgroundTaskLabel(task)}</span>
</li>
))}
</ul>
) : (
<p>
{translate(
'components.native-chat.backgroundTasks.detailsUnavailable',
'Task details are unavailable for this session.'
)}
</p>
)}
</div>
) : null}
</div>
</div>
)
@@ -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())
@@ -276,6 +276,7 @@ export function NativeChatStructuredSession(
) : null}
{controller.isMonitoringBackgroundTasks ? (
<NativeChatBackgroundTasksStatus
tasks={controller.backgroundTasks}
stopping={stoppingBackgroundTasks}
onStop={() => {
setStoppingBackgroundTasks(true)
@@ -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: () =>
+11 -1
View File
@@ -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. */
@@ -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', () => {
@@ -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',
+26 -2
View File
@@ -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
) {