diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt index 77267d0c..fc021b82 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt @@ -67,6 +67,8 @@ object SessionPatches { "model", "modelReasoningEffort", "effort", "serviceTier", "permissionMode", "collaborationMode", "copilotAgentMode", "backgroundTaskCount", "scratchlistUpdatedAt", + // Web-only status-panel list; accepted so the patch is not refetched. + "backgroundTasks", ) /** diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt index 35b0b2ef..9a6fdc29 100644 --- a/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/patch/SessionPatchingTest.kt @@ -372,6 +372,18 @@ class SessionPatchingTest { assertNull(SessionPatches.parse(fullSession)) } + @Test + fun `parse accepts background task patches`() { + val patch = assertNotNull( + SessionPatches.parse( + HapiJson.parseToJsonElement( + """{"backgroundTaskCount":1,"backgroundTasks":[{"id":"b1","type":"local_bash","description":"Run tests"}]}""" + ) + ) + ) + assertEquals(1, patch.backgroundTaskCount) + } + @Test fun `parse rejects an empty patch`() { assertNull(SessionPatches.parse(HapiJson.parseToJsonElement("{}"))) diff --git a/docs/api/client-contract/sse.md b/docs/api/client-contract/sse.md index ba4eebce..aa206434 100644 --- a/docs/api/client-contract/sse.md +++ b/docs/api/client-contract/sse.md @@ -165,7 +165,7 @@ The most bug-prone part of the protocol. `session-updated.data` is either a **fu Patch application, field by field: -- **Flat fields** — `active`, `thinking`, `activeAt`, `model`, `modelReasoningEffort`, `effort`, `serviceTier`, `permissionMode`, `collaborationMode`, `copilotAgentMode`, `backgroundTaskCount`: last-write-wins assignment when present. `activeTurnStartedAt` appears in patches but the reference implementation deliberately never applies it from a patch (`web/src/lib/sessionPatch.ts`) — take it from full-session payloads only; the `sse/` fixtures pin this. +- **Flat fields** — `active`, `thinking`, `activeAt`, `model`, `modelReasoningEffort`, `effort`, `serviceTier`, `permissionMode`, `collaborationMode`, `copilotAgentMode`, `backgroundTaskCount`, `backgroundTasks` (Claude's full `{id, type, description}` list): last-write-wins assignment when present. `activeTurnStartedAt` appears in patches but the reference implementation deliberately never applies it from a patch (`web/src/lib/sessionPatch.ts`) — take it from full-session payloads only; the `sse/` fixtures pin this. - **`updatedAt`** — max-monotonic: `updatedAt = max(cached.updatedAt, patch.updatedAt)`. A stale replay must never move the clock backward. - **Versioned sub-patches** — `metadata`, `agentState`, `todos`, `teamState` each arrive as a wrapper `{version: number, value: …}`. Apply `value` and store `version` **only when `version` is strictly greater than the cached watermark**: diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index d1b8b57f..d17de2d2 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -1,4 +1,4 @@ -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { BackgroundTask, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredMachine, StoredSession } from '../../../store' import type { RpcRegistry } from '../../rpcRegistry' import type { SyncEvent } from '../../../sync/syncEngine' @@ -48,7 +48,7 @@ export type CliHandlersDeps = { onSessionEnd?: (payload: SessionEndPayload) => void onMachineAlive?: (payload: MachineAlivePayload) => void onWebappEvent?: (event: SyncEvent) => void - onBackgroundTaskCount?: (sessionId: string, count: number) => void + onBackgroundTasks?: (sessionId: string, tasks: BackgroundTask[]) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onAgentProgress?: (sessionId: string, at: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void @@ -84,7 +84,7 @@ const SESSION_ACCESS_CACHE_TTL_MS = 1_000 const SESSION_ACCESS_CACHE_MAX_SESSIONS = 64 export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlersDeps): void { - const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionReady, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTaskCount, onSessionActivity, onAgentProgress, onSweepImmediateQueued, onMessagesConsumed } = deps + const { io, store, rpcRegistry, terminalRegistry, onSessionAlive, onSessionReady, onSessionEnd, onMachineAlive, onWebappEvent, onBackgroundTasks, onSessionActivity, onAgentProgress, onSweepImmediateQueued, onMessagesConsumed } = deps const terminalNamespace = io.of('/terminal') const namespace = typeof socket.data.namespace === 'string' ? socket.data.namespace : null @@ -178,7 +178,7 @@ export function registerCliHandlers(socket: CliSocketWithData, deps: CliHandlers onSessionReady, onSessionEnd, onWebappEvent, - onBackgroundTaskCount, + onBackgroundTasks, onSessionActivity, onAgentProgress, onSweepImmediateQueued, diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 10b6f76f..e65439e6 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -2,13 +2,13 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' import type { CopilotAgentMode } from '@hapi/protocol' -import type { AgentState, CodexCollaborationMode, Metadata, PermissionMode } from '@hapi/protocol/types' +import type { AgentState, BackgroundTask, CodexCollaborationMode, Metadata, PermissionMode } from '@hapi/protocol/types' import { getReasoningStreamId, isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams' -import { extractBackgroundTaskCount } from '../../../sync/backgroundTasks' +import { extractBackgroundTasks } from '../../../sync/backgroundTasks' import { shouldRecordSessionActivity } from '../../../sync/sessionActivity' import type { CliSocketWithData } from '../../socketTypes' import type { SessionEndReason } from '@hapi/protocol' @@ -91,7 +91,7 @@ export type SessionHandlersDeps = { onSessionReady?: (payload: SessionReadyPayload) => void onSessionEnd?: (payload: SessionEndPayload) => void onWebappEvent?: (event: SyncEvent) => void - onBackgroundTaskCount?: (sessionId: string, count: number) => void + onBackgroundTasks?: (sessionId: string, tasks: BackgroundTask[]) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void /** tiann/hapi#1820: any message is agent progress, either direction. */ onAgentProgress?: (sessionId: string, at: number) => void @@ -103,7 +103,7 @@ export type SessionHandlersDeps = { } export function registerSessionHandlers(socket: CliSocketWithData, deps: SessionHandlersDeps): void { - const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTaskCount, onSessionActivity, onAgentProgress, onSweepImmediateQueued, onMessagesConsumed } = deps + const { store, resolveSessionAccess, emitAccessError, onSessionAlive, onSessionReady, onSessionEnd, onWebappEvent, onBackgroundTasks, onSessionActivity, onAgentProgress, onSweepImmediateQueued, onMessagesConsumed } = deps socket.on('native-queue-message', data => { const parsed = z.object({ sid: z.string(), localId: z.string().min(1), text: z.string().nullable() }).safeParse(data) @@ -161,9 +161,9 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session // A background-task snapshot is session state, not chat: apply it // without storing a hidden row. - const backgroundTaskCount = extractBackgroundTaskCount(content) - if (backgroundTaskCount !== null) { - onBackgroundTaskCount?.(sid, backgroundTaskCount) + const backgroundTasks = extractBackgroundTasks(content) + if (backgroundTasks !== null) { + onBackgroundTasks?.(sid, backgroundTasks) return } diff --git a/hub/src/socket/server.ts b/hub/src/socket/server.ts index 32c2ab94..3cd02955 100644 --- a/hub/src/socket/server.ts +++ b/hub/src/socket/server.ts @@ -10,6 +10,7 @@ import { registerCliHandlers } from './handlers/cli' import { registerTerminalHandlers } from './handlers/terminal' import { RpcRegistry } from './rpcRegistry' import { SOCKET_MAX_HTTP_BUFFER_SIZE } from './socketLimits' +import type { BackgroundTask } from '@hapi/protocol/types' import type { SyncEvent } from '../sync/syncEngine' import { TerminalRegistry } from './terminalRegistry' import { clearUserTerminalBuffer } from './userTerminalBuffer' @@ -42,7 +43,7 @@ export type SocketServerDeps = { onSessionReady?: (payload: { sid: string; time: number }) => void onSessionEnd?: (payload: { sid: string; time: number }) => void onMachineAlive?: (payload: { machineId: string; time: number; health?: unknown }) => void - onBackgroundTaskCount?: (sessionId: string, count: number) => void + onBackgroundTasks?: (sessionId: string, tasks: BackgroundTask[]) => void onSessionActivity?: (sessionId: string, updatedAt: number) => void onAgentProgress?: (sessionId: string, at: number) => void onSweepImmediateQueued?: (sessionId: string, now: number) => void @@ -131,7 +132,7 @@ export function createSocketServer(deps: SocketServerDeps): { onSessionEnd: deps.onSessionEnd, onMachineAlive: deps.onMachineAlive, onWebappEvent: deps.onWebappEvent, - onBackgroundTaskCount: deps.onBackgroundTaskCount, + onBackgroundTasks: deps.onBackgroundTasks, onSessionActivity: deps.onSessionActivity, onAgentProgress: deps.onAgentProgress, onSweepImmediateQueued: deps.onSweepImmediateQueued, diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 21149e59..6521db90 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -201,7 +201,7 @@ export async function startHub(options: StartHubOptions = {}): Promise syncEngine?.handleSessionReady(payload), onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload), - onBackgroundTaskCount: (sessionId, count) => syncEngine?.handleBackgroundTaskCount(sessionId, count), + onBackgroundTasks: (sessionId, tasks) => syncEngine?.handleBackgroundTasks(sessionId, tasks), onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt), onAgentProgress: (sessionId, at) => syncEngine?.recordAgentProgress(sessionId, at), onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now), diff --git a/hub/src/sync/backgroundTasks.ts b/hub/src/sync/backgroundTasks.ts index 8ed5c5a1..2712227c 100644 --- a/hub/src/sync/backgroundTasks.ts +++ b/hub/src/sync/backgroundTasks.ts @@ -1,22 +1,28 @@ import { isObject } from '@hapi/protocol' import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' +import type { BackgroundTask } from '@hapi/protocol/types' /** - * Read the running background task count from Claude's + * Read the running background tasks from Claude's * `system/background_tasks_changed` snapshot. * * Claude Code (SDK mode) emits it whenever the set of background tasks * changes, including an empty `tasks` list once the last one finishes. The * CLI forwards it as { role: 'agent', content: { type: 'output', data } }. - * Being a full snapshot, it replaces the count instead of adjusting it. + * Being a full snapshot, it replaces the list instead of adjusting it. */ -export function extractBackgroundTaskCount(messageContent: unknown): number | null { +export function extractBackgroundTasks(messageContent: unknown): BackgroundTask[] | null { const record = unwrapRoleWrappedRecordEnvelope(messageContent) if (!record || record.role !== 'agent') return null if (!isObject(record.content) || record.content.type !== 'output') return null const data = isObject(record.content.data) ? record.content.data : null if (data?.type !== 'system' || data.subtype !== 'background_tasks_changed') return null + if (!Array.isArray(data.tasks)) return null - return Array.isArray(data.tasks) ? data.tasks.length : null + return data.tasks.filter(isObject).map((task) => ({ + id: typeof task.task_id === 'string' ? task.task_id : '', + type: typeof task.task_type === 'string' ? task.task_type : '', + description: typeof task.description === 'string' ? task.description : '' + })) } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 66bbb933..fb52016b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,6 +1,6 @@ import { SESSION_LIFECYCLE_IDLE, SESSION_LIFECYCLE_RUNNING } from '@hapi/protocol' import { AgentStateSchema, MetadataSchema, SessionPatchSchema, TeamStateSchema } from '@hapi/protocol/schemas' -import type { CodexCollaborationMode, CopilotAgentMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' +import type { BackgroundTask, CodexCollaborationMode, CopilotAgentMode, PermissionMode, Session, SessionPatch } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' @@ -240,6 +240,7 @@ export class SessionCache { thinkingAt: existing?.thinkingAt ?? 0, activeTurnStartedAt: existing?.activeTurnStartedAt ?? null, backgroundTaskCount: existing?.backgroundTaskCount ?? 0, + backgroundTasks: existing?.backgroundTasks ?? [], todos, teamState, todosUpdatedAt: stored.todosUpdatedAt ?? 0, @@ -370,6 +371,7 @@ export class SessionCache { if (patch.permissionMode !== undefined) session.permissionMode = patch.permissionMode if (patch.collaborationMode !== undefined) session.collaborationMode = patch.collaborationMode if (patch.backgroundTaskCount !== undefined) session.backgroundTaskCount = patch.backgroundTaskCount + if (patch.backgroundTasks !== undefined) session.backgroundTasks = patch.backgroundTasks if (patch.todos !== undefined) { session.todos = patch.todos.value session.todosUpdatedAt = patch.todos.version @@ -571,17 +573,21 @@ export class SessionCache { } } - setBackgroundTaskCount(sessionId: string, next: number): void { + setBackgroundTasks(sessionId: string, tasks: BackgroundTask[]): void { const session = this.sessions.get(sessionId) if (!session) return - if (next === (session.backgroundTaskCount ?? 0)) return + const current = session.backgroundTasks ?? [] + if (tasks.length === current.length && tasks.every((task, index) => + task.id === current[index]?.id && task.description === current[index]?.description + )) return - session.backgroundTaskCount = next + session.backgroundTasks = tasks + session.backgroundTaskCount = tasks.length this.recordAgentProgress(sessionId) this.publisher.emit({ type: 'session-updated', sessionId, - data: { backgroundTaskCount: next } satisfies SessionPatch + data: { backgroundTaskCount: tasks.length, backgroundTasks: tasks } satisfies SessionPatch }) } @@ -771,12 +777,13 @@ export class SessionCache { session.thinkingAt = t session.activeTurnStartedAt = null session.backgroundTaskCount = 0 + session.backgroundTasks = [] this.pendingThinkingUntilBySessionId.delete(session.id) this.publisher.emit({ type: 'session-updated', sessionId: session.id, - data: { active: false, thinking: false, activeTurnStartedAt: null, backgroundTaskCount: 0 } satisfies SessionPatch + data: { active: false, thinking: false, activeTurnStartedAt: null, backgroundTaskCount: 0, backgroundTasks: [] } satisfies SessionPatch }) } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 7c5231ea..c2ac0ad8 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -978,7 +978,7 @@ describe('session model', () => { expect(activity[0].updatedAt).toBe(messages[0]?.createdAt) }) - it('sets backgroundTaskCount from Claude background_tasks_changed snapshots', () => { + it('sets background tasks from Claude background_tasks_changed snapshots', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] const cache = new SessionCache(store, createPublisher(events)) @@ -1002,7 +1002,7 @@ describe('session model', () => { return stored ? { ok: true, value: stored } : { ok: false, reason: 'not-found' } }, emitAccessError: () => {}, - onBackgroundTaskCount: (sessionId, count) => cache.setBackgroundTaskCount(sessionId, count) + onBackgroundTasks: (sessionId, tasks) => cache.setBackgroundTasks(sessionId, tasks) }) const send = (data: unknown) => handlers.get('message')?.({ sid: session.id, @@ -1012,11 +1012,15 @@ describe('session model', () => { type: 'system', subtype: 'background_tasks_changed', uuid: `snapshot-${taskIds.length}`, - tasks: taskIds.map((task_id) => ({ task_id, task_type: 'local_bash' })) + tasks: taskIds.map((task_id) => ({ task_id, task_type: 'local_bash', description: `run ${task_id}` })) }) snapshot(['b1', 'b2']) expect(cache.getSession(session.id)?.backgroundTaskCount).toBe(2) + expect(cache.getSession(session.id)?.backgroundTasks).toEqual([ + { id: 'b1', type: 'local_bash', description: 'run b1' }, + { id: 'b2', type: 'local_bash', description: 'run b2' } + ]) // The Bash tool result that announces a background task is not a count signal. send({ diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 699e8345..bfbc13fc 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -15,7 +15,7 @@ import { import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessageDeliveryMode, MessagesResponse, QueuedStateResponse, RewindConversationErrorCode, SlashCommandsResponse } from '@hapi/protocol/apiTypes' import type { SteerQueuedMessageResponse } from '@hapi/protocol/schemas' import type { ImplementCodexPlanResult } from '@hapi/protocol/apiTypes' -import type { AgentFlavor, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { AgentFlavor, BackgroundTask, CodexCollaborationMode, CopilotAgentMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import { hasConversationMessageContent, unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import { randomUUID } from 'node:crypto' @@ -698,8 +698,8 @@ export class SyncEngine { clearAgentTerminalBuffer(payload.sid) } - handleBackgroundTaskCount(sessionId: string, count: number): void { - this.sessionCache.setBackgroundTaskCount(sessionId, count) + handleBackgroundTasks(sessionId: string, tasks: BackgroundTask[]): void { + this.sessionCache.setBackgroundTasks(sessionId, tasks) } recordSessionActivity(sessionId: string, updatedAt: number): void { diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift index 7ba26ef5..1f3a9068 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift @@ -130,6 +130,8 @@ extension SessionPatch: Codable { case copilotAgentMode case backgroundTaskCount case scratchlistUpdatedAt + // Web-only status-panel list; accepted so the patch is not refetched. + case backgroundTasks } public init(from decoder: Decoder) throws { diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift index b56fd236..54c681d8 100644 --- a/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/SyncEventDecodingTests.swift @@ -100,6 +100,21 @@ struct SyncEventDecodingTests { #expect(raw == ["activeAt": 1, "someFutureField": 2]) } + @Test func acceptsBackgroundTaskPatches() throws { + let event = try decode(""" + { + "type": "session-updated", + "sessionId": "s-1", + "data": {"backgroundTaskCount": 1, "backgroundTasks": [{"id": "b1", "type": "local_bash", "description": "Run tests"}]} + } + """) + guard case .sessionUpdated(_, _, .patch(let patch)?) = event else { + Issue.record("expected .sessionUpdated patch, got \(event)") + return + } + #expect(patch.backgroundTaskCount == 1) + } + @Test func decodesSessionAddedLikeSessionUpdated() throws { let event = try decode(""" {"type": "session-added", "sessionId": "s-2", "data": {"thinking": true}} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 7ba27364..99f8b593 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -336,6 +336,15 @@ export const DecryptedMessageSchema = z.object({ export type DecryptedMessage = z.infer +// One entry of Claude's `system/background_tasks_changed` snapshot. +export const BackgroundTaskSchema = z.object({ + id: z.string(), + type: z.string(), + description: z.string() +}) + +export type BackgroundTask = z.infer + export const SessionSchema = z.object({ hasConversationContent: z.boolean().optional(), id: z.string(), @@ -356,6 +365,7 @@ export const SessionSchema = z.object({ thinkingAt: z.number(), activeTurnStartedAt: z.number().nullable().optional(), backgroundTaskCount: z.number().optional(), + backgroundTasks: z.array(BackgroundTaskSchema).optional(), todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), // Watermarks for structured SSE patches (PR #897). Dual EventSource @@ -430,6 +440,7 @@ export const SessionPatchSchema = z.object({ collaborationMode: CodexCollaborationModeSchema.optional(), copilotAgentMode: CopilotAgentModeSchema.optional(), backgroundTaskCount: z.number().optional(), + backgroundTasks: z.array(BackgroundTaskSchema).optional(), // tiann/hapi#893 (scratchlist v2). Bumped whenever any entry on the // session_scratchlist table mutates. Web client uses the change as a // trigger to refetch the entries query - the timestamp itself is the diff --git a/shared/src/types.ts b/shared/src/types.ts index df693464..f7bd9566 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -3,6 +3,7 @@ export type { AgentStateCompletedRequest, AgentStateRequest, AttachmentMetadata, + BackgroundTask, DecryptedMessage, Metadata, Machine, diff --git a/web/src/chat/sessionStatus.test.ts b/web/src/chat/sessionStatus.test.ts index febb458a..a20adf29 100644 --- a/web/src/chat/sessionStatus.test.ts +++ b/web/src/chat/sessionStatus.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { ChatBlock, NormalizedMessage, ToolCallBlock } from '@/chat/types' +import type { ChatBlock, ToolCallBlock } from '@/chat/types' import { buildSessionStatusData } from './sessionStatus' function toolBlock(overrides: Omit, 'tool'> & { @@ -30,25 +30,9 @@ function toolBlock(overrides: Omit, 'tool'> & { } } -function taskNotification(taskId?: string): NormalizedMessage { - return { - id: `notification-${taskId ?? 'anonymous'}`, - localId: null, - createdAt: 200, - role: 'agent', - isSidechain: true, - content: [{ - type: 'sidechain', - uuid: 'notification', - parentUUID: null, - prompt: `${taskId ? `${taskId}` : ''}Done` - }] - } -} - describe('buildSessionStatusData', () => { it('returns null when the session has no hidden status', () => { - expect(buildSessionStatusData({ goal: null, tasks: [], blocks: [], messages: [] })).toBeNull() + expect(buildSessionStatusData({ goal: null, tasks: [], blocks: [] })).toBeNull() }) it('keeps goal and ordered task snapshots', () => { @@ -67,7 +51,7 @@ describe('buildSessionStatusData', () => { { id: '2', content: 'Implement', priority: 'high' as const, status: 'in_progress' as const } ] - expect(buildSessionStatusData({ goal, tasks, blocks: [], messages: [] })).toMatchObject({ goal, tasks }) + expect(buildSessionStatusData({ goal, tasks, blocks: [] })).toMatchObject({ goal, tasks }) }) it('lists only active or failed subagents', () => { @@ -84,7 +68,7 @@ describe('buildSessionStatusData', () => { }) ] - expect(buildSessionStatusData({ goal: null, tasks: [], blocks, messages: [] })?.subagents).toEqual([ + expect(buildSessionStatusData({ goal: null, tasks: [], blocks })?.subagents).toEqual([ expect.objectContaining({ id: 'agent-1', title: 'Inspect API', detail: 'Reading files', state: 'running', endedAt: null }), expect.objectContaining({ id: 'agent-2', title: 'Review changes', state: 'error', endedAt: 100 }), expect.objectContaining({ id: 'agent-pending', state: 'waiting', startedAt: null }), @@ -92,7 +76,7 @@ describe('buildSessionStatusData', () => { ]) }) - it('tracks Claude background terminals until their task notification arrives', () => { + it('lists Claude background tasks with the command that started them', () => { const blocks: ChatBlock[] = [toolBlock({ name: 'Agent', tool: { state: 'completed' }, @@ -110,46 +94,38 @@ describe('buildSessionStatusData', () => { toolBlock({ name: 'Bash', tool: { - id: 'bash-2', + id: 'bash-ls', state: 'completed', - input: { command: 'bun test --watch' }, - result: [{ type: 'text', text: 'Command running in background with ID: bg-2' }] + input: { command: 'ls tasks' }, + result: 'bg-2.output\nbg-3.output' } }), toolBlock({ - name: 'Read', - tool: { result: 'Command running in background with ID: not-a-terminal' } + name: 'Bash', + tool: { + id: 'bash-2', + state: 'completed', + input: { command: 'bun test --watch' }, + result: [{ type: 'text', text: 'Command was moved to the background (ID: bg-2).' }] + } }) ] })] - expect(buildSessionStatusData({ goal: null, tasks: [], blocks, messages: [taskNotification('bg-1')] })?.terminals).toEqual([ - { id: 'bg-2', command: 'bun test --watch', cwd: null, startedAt: 100 } + expect(buildSessionStatusData({ + goal: null, + tasks: [], + blocks, + backgroundTasks: [ + { id: 'bg-2', type: 'local_bash', description: 'Watch tests' }, + { id: 'bg-3', type: 'local_bash', description: 'Start server' }, + { id: 'm-1', type: 'monitor_mcp', description: 'Watch deploy' } + ] + })?.terminals).toEqual([ + { id: 'bg-2', command: 'bun test --watch', cwd: null, startedAt: 100 }, + { id: 'bg-3', command: 'Start server', cwd: null, startedAt: null }, + { id: 'm-1', command: 'Watch deploy', cwd: null, startedAt: null } ]) - expect(buildSessionStatusData({ - goal: null, - tasks: [], - blocks, - messages: [], - backgroundTaskCount: 0 - })).toBeNull() - expect(buildSessionStatusData({ - goal: null, - tasks: [], - blocks: [], - messages: [], - backgroundTaskCount: 2 - })).toMatchObject({ terminals: [], undiscoveredTerminalCount: 2 }) - expect(buildSessionStatusData({ - goal: null, - tasks: [], - blocks, - messages: [taskNotification()], - backgroundTaskCount: 1 - })).toMatchObject({ - terminals: [], - undiscoveredTerminalCount: 1, - possibleTerminalCommands: ['bun run dev', 'bun test --watch'] - }) + expect(buildSessionStatusData({ goal: null, tasks: [], blocks, backgroundTasks: [] })).toBeNull() }) }) diff --git a/web/src/chat/sessionStatus.ts b/web/src/chat/sessionStatus.ts index 88f9f4ae..8db39268 100644 --- a/web/src/chat/sessionStatus.ts +++ b/web/src/chat/sessionStatus.ts @@ -1,6 +1,6 @@ import { isObject } from '@hapi/protocol' -import type { ThreadGoal, TodoItem } from '@/types/api' -import type { ChatBlock, NormalizedMessage, ToolCallBlock } from '@/chat/types' +import type { BackgroundTask, ThreadGoal, TodoItem } from '@/types/api' +import type { ChatBlock, ToolCallBlock } from '@/chat/types' import { isSubagentToolName } from '@/chat/subagentTool' import { getCodexAgentActivity, getCodexAgentSummary } from '@/components/ToolCard/codexAgents' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' @@ -18,7 +18,7 @@ export type SessionStatusTerminal = { id: string command: string cwd: string | null - startedAt: number + startedAt: number | null } export type SessionStatusData = { @@ -26,21 +26,18 @@ export type SessionStatusData = { tasks: TodoItem[] subagents: SessionStatusSubagent[] terminals: SessionStatusTerminal[] - undiscoveredTerminalCount: number - possibleTerminalCommands: string[] } -const BACKGROUND_START_RE = /Command running in background with ID:\s*([^\s<]+)/ -const TASK_ID_RE = /\s*([^<]+?)\s*<\/task-id>/ - -function findBackgroundTaskId(value: unknown): string | null { - if (typeof value === 'string') return value.match(BACKGROUND_START_RE)?.[1] ?? null +function matchesText(value: unknown, pattern: RegExp): boolean { + if (typeof value === 'string') return pattern.test(value) const values = Array.isArray(value) ? value : isObject(value) ? Object.values(value) : [] - for (const item of values) { - const taskId = findBackgroundTaskId(item) - if (taskId) return taskId - } - return null + return values.some((item) => matchesText(item, pattern)) +} + +// Matches "running in background with ID: " and "moved to the background (ID: )". +function backgroundLaunchPattern(taskId: string): RegExp { + const id = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`background[^\\n]*\\bID: ${id}(?![\\w-])`) } function commandFromInput(input: unknown): string | null { @@ -51,19 +48,6 @@ function commandFromInput(input: unknown): string | null { return parts.length > 0 ? parts.join(' ') : null } -function terminalFromBlock(block: ToolCallBlock): SessionStatusTerminal | null { - if (block.tool.name !== 'Bash') return null - const taskId = findBackgroundTaskId(block.tool.result) - if (!taskId) return null - - return { - id: taskId, - command: commandFromInput(block.tool.input) ?? block.tool.description ?? block.tool.name, - cwd: getInputStringAny(block.tool.input, ['cwd', 'workdir', 'path']), - startedAt: block.tool.startedAt ?? block.createdAt - } -} - function collectToolBlocks(blocks: readonly ChatBlock[]): ToolCallBlock[] { const tools: ToolCallBlock[] = [] for (const block of blocks) { @@ -73,43 +57,24 @@ function collectToolBlocks(blocks: readonly ChatBlock[]): ToolCallBlock[] { return tools } -function backgroundTaskCompletions(messages: readonly NormalizedMessage[]): { - ids: Set - latestUnknownAt: number | null -} { - const ids = new Set() - let latestUnknownAt: number | null = null - - for (const message of messages) { - if (message.role !== 'agent') continue - for (const content of message.content) { - if (content.type !== 'sidechain') continue - const prompt = content.prompt.trimStart() - if (!prompt.startsWith('')) continue - const taskId = prompt.match(TASK_ID_RE)?.[1]?.trim() - if (taskId) ids.add(taskId) - else latestUnknownAt = Math.max(latestUnknownAt ?? 0, message.createdAt) - } - } - - return { ids, latestUnknownAt } -} - function buildBackgroundTerminals( tools: readonly ToolCallBlock[], - messages: readonly NormalizedMessage[] -): { confirmed: SessionStatusTerminal[]; uncertain: SessionStatusTerminal[] } { - const terminals = tools - .map(terminalFromBlock) - .filter((terminal): terminal is SessionStatusTerminal => terminal !== null) - const completions = backgroundTaskCompletions(messages) - const remaining = terminals.filter((terminal) => !completions.ids.has(terminal.id)) - const latestUnknownAt = completions.latestUnknownAt - if (latestUnknownAt === null) return { confirmed: remaining, uncertain: [] } - return { - confirmed: remaining.filter((terminal) => terminal.startedAt > latestUnknownAt), - uncertain: remaining.filter((terminal) => terminal.startedAt <= latestUnknownAt) - } + tasks: readonly BackgroundTask[] +): SessionStatusTerminal[] { + return tasks.map((task) => { + // Claude's snapshot carries the Bash description, not the command; + // the tool result that started the task names its exact ID. + const launch = task.type === 'local_bash' && task.id ? backgroundLaunchPattern(task.id) : null + const block = launch + ? tools.find((tool) => tool.tool.name === 'Bash' && matchesText(tool.tool.result, launch)) + : undefined + return { + id: task.id, + command: (block && commandFromInput(block.tool.input)) || task.description || task.type, + cwd: block ? getInputStringAny(block.tool.input, ['cwd', 'workdir', 'path']) : null, + startedAt: block ? block.tool.startedAt ?? block.createdAt : null + } + }) } function subagentTitle(block: ToolCallBlock): string { @@ -152,38 +117,22 @@ export function buildSessionStatusData(args: { goal: ThreadGoal | null | undefined tasks: readonly TodoItem[] | null | undefined blocks: readonly ChatBlock[] - messages: readonly NormalizedMessage[] - backgroundTaskCount?: number + backgroundTasks?: readonly BackgroundTask[] }): SessionStatusData | null { const tools = collectToolBlocks(args.blocks) - const detectedTerminals = buildBackgroundTerminals(tools, args.messages) - const terminals = args.backgroundTaskCount === undefined - ? [...detectedTerminals.confirmed, ...detectedTerminals.uncertain] - : args.backgroundTaskCount > 0 - ? detectedTerminals.confirmed.slice(-args.backgroundTaskCount) - : [] - const undiscoveredTerminalCount = args.backgroundTaskCount === undefined - ? 0 - : Math.max(0, args.backgroundTaskCount - terminals.length) - const possibleTerminalCommands = undiscoveredTerminalCount > 0 - ? detectedTerminals.uncertain.map((terminal) => terminal.command) - : [] const data: SessionStatusData = { goal: args.goal ?? null, tasks: args.tasks ? [...args.tasks] : [], subagents: tools .map(subagentFromBlock) .filter((subagent): subagent is SessionStatusSubagent => subagent !== null), - terminals, - undiscoveredTerminalCount, - possibleTerminalCommands + terminals: buildBackgroundTerminals(tools, args.backgroundTasks ?? []) } return data.goal || data.tasks.length > 0 || data.subagents.length > 0 || data.terminals.length > 0 - || data.undiscoveredTerminalCount > 0 ? data : null } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 01432894..9d6bc55a 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1521,15 +1521,13 @@ function SessionChatInner(props: SessionChatProps) { goal: reduced.latestGoal, tasks: props.session.todos, blocks: reconciled.blocks, - messages: normalizedMessages, - backgroundTaskCount: props.session.backgroundTaskCount + backgroundTasks: props.session.backgroundTasks }), [ reduced.latestGoal, props.session.todos, - props.session.backgroundTaskCount, - reconciled.blocks, - normalizedMessages + props.session.backgroundTasks, + reconciled.blocks ] ) const hasRunningChildAgent = useMemo( diff --git a/web/src/components/SessionStatusPanel.tsx b/web/src/components/SessionStatusPanel.tsx index 75f42b95..e66f5087 100644 --- a/web/src/components/SessionStatusPanel.tsx +++ b/web/src/components/SessionStatusPanel.tsx @@ -91,7 +91,7 @@ export function SessionStatusPanel({ data }: { data: SessionStatusData }) { ) : null} - {data.terminals.length > 0 || data.undiscoveredTerminalCount > 0 ? ( + {data.terminals.length > 0 ? (
{data.terminals.map((terminal) => { @@ -109,21 +109,6 @@ export function SessionStatusPanel({ data }: { data: SessionStatusData }) {
) })} - {data.undiscoveredTerminalCount > 0 ? ( - <> -
- {t('session.status.terminalsUnavailable', { count: data.undiscoveredTerminalCount })} -
- {data.possibleTerminalCommands.length > 0 ? ( -
- {t('session.status.terminalsPossible')} - {data.possibleTerminalCommands.map((command, index) => ( - {command} - ))} -
- ) : null} - - ) : null}
) : null} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d390a6ce..51241239 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -1229,9 +1229,7 @@ export default { 'session.status.goal': 'Goal', 'session.status.tasks': 'Tasks', 'session.status.subagents': 'Subagents', - 'session.status.terminals': 'Background terminals', - 'session.status.terminalsUnavailable': '{count} running · exact command unavailable', - 'session.status.terminalsPossible': 'Possible commands', + 'session.status.terminals': 'Background tasks', 'session.status.goal.active': 'Active', 'session.status.goal.paused': 'Paused', 'session.status.goal.budgetLimited': 'Limited', diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts index 9b44fa39..59ca9d93 100644 --- a/web/src/lib/locales/ru.ts +++ b/web/src/lib/locales/ru.ts @@ -1210,9 +1210,7 @@ export default { 'session.status.goal': 'Цель', 'session.status.tasks': 'Задачи', 'session.status.subagents': 'Подагенты', - 'session.status.terminals': 'Фоновые терминалы', - 'session.status.terminalsUnavailable': 'Выполняется: {count} · точная команда недоступна', - 'session.status.terminalsPossible': 'Возможные команды', + 'session.status.terminals': 'Фоновые задачи', 'session.status.goal.active': 'Активна', 'session.status.goal.paused': 'Приостановлена', 'session.status.goal.budgetLimited': 'Ограничена', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2691a1a5..4fa15db1 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -1227,9 +1227,7 @@ export default { 'session.status.goal': '目标', 'session.status.tasks': '任务', 'session.status.subagents': '子代理', - 'session.status.terminals': '后台终端', - 'session.status.terminalsUnavailable': '{count} 个终端运行中 · 无法确定具体命令', - 'session.status.terminalsPossible': '可能的命令', + 'session.status.terminals': '后台任务', 'session.status.goal.active': '进行中', 'session.status.goal.paused': '已暂停', 'session.status.goal.budgetLimited': '已受限', diff --git a/web/src/lib/sessionPatch.ts b/web/src/lib/sessionPatch.ts index 8e22c14b..e6f989eb 100644 --- a/web/src/lib/sessionPatch.ts +++ b/web/src/lib/sessionPatch.ts @@ -81,6 +81,7 @@ export function applySessionDetailPatch(session: Session, patch: SessionPatch): if (patch.collaborationMode !== undefined) assign('collaborationMode', patch.collaborationMode) if (patch.copilotAgentMode !== undefined) assign('copilotAgentMode', patch.copilotAgentMode) if (patch.backgroundTaskCount !== undefined) assign('backgroundTaskCount', patch.backgroundTaskCount) + if (patch.backgroundTasks !== undefined) assign('backgroundTasks', patch.backgroundTasks) // Version gates: dual SSE can deliver duplicates out of order. // Only mark changed when a strictly newer version lands — // otherwise keep previous object identity (no redundant render). diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 54d85121..0563ed43 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -63,6 +63,7 @@ export type { export type { AgentState, AttachmentMetadata, + BackgroundTask, CodexCollaborationMode, CopilotAgentMode, Metadata,