From fe2fbb881d3f52e480b8bedf8d0645d77cd25d90 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Wed, 28 Oct 2026 12:12:41 +0800 Subject: [PATCH] fix: list Claude background tasks from the snapshot instead of guessing (#2177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: list Claude background tasks from the snapshot instead of guessing The status panel only had a count from `background_tasks_changed` and reconstructed commands by regex-matching Bash results in loaded chat history, so tasks whose start message was unloaded, auto-backgrounded ("moved to the background (ID: …)"), or non-Bash showed "N running · exact command unavailable". The hub now keeps the snapshot's tasks (id, type, description) on the session alongside the count. The web panel lists them directly, using the exact task ID to look up the Bash command when loaded and the snapshot description otherwise. Co-Authored-By: Claude Opus 5.5 * fix(web): only attribute a Bash command from its background launch result Co-Authored-By: Claude Opus 5.5 * fix(native): accept backgroundTasks in strict session patch decoders Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../app/hapi/protocol/wire/SessionPatch.kt | 2 + .../protocol/patch/SessionPatchingTest.kt | 12 ++ docs/api/client-contract/sse.md | 2 +- hub/src/socket/handlers/cli/index.ts | 8 +- .../socket/handlers/cli/sessionHandlers.ts | 14 +-- hub/src/socket/server.ts | 5 +- hub/src/startHub.ts | 2 +- hub/src/sync/backgroundTasks.ts | 14 ++- hub/src/sync/sessionCache.ts | 19 ++- hub/src/sync/sessionModel.test.ts | 10 +- hub/src/sync/syncEngine.ts | 6 +- .../HapiProtocol/Models/SessionPatch.swift | 2 + .../SyncEventDecodingTests.swift | 15 +++ shared/src/schemas.ts | 11 ++ shared/src/types.ts | 1 + web/src/chat/sessionStatus.test.ts | 82 +++++-------- web/src/chat/sessionStatus.ts | 111 +++++------------- web/src/components/SessionChat.tsx | 8 +- web/src/components/SessionStatusPanel.tsx | 17 +-- web/src/lib/locales/en.ts | 4 +- web/src/lib/locales/ru.ts | 4 +- web/src/lib/locales/zh-CN.ts | 4 +- web/src/lib/sessionPatch.ts | 1 + web/src/types/api.ts | 1 + 24 files changed, 160 insertions(+), 195 deletions(-) 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,