mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Show running Claude background task details
This commit is contained in:
@@ -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)
|
||||
|
||||
+6
-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: () =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user