diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 92e27425..fbc63835 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -10,6 +10,7 @@ import { hasVisibleSessionActivity, } from '../components/activity/sessionActivityModel' import { useSessionRuntimeStore } from './sessionRuntimeStore' +import { hasRunningBackgroundTasks } from '../lib/backgroundTasks' import { registerSideChatSession, unregisterSideChatSession } from '../lib/sideChatSessions' const { @@ -4244,6 +4245,85 @@ describe('chatStore history mapping', () => { }) }) + it('restores a background shell stopped by TaskStop when no notification was recorded', () => { + const shellStart = (toolUseId: string, taskId: string, minute: number): MessageEntry[] => [ + { + id: `${toolUseId}-use`, + type: 'assistant', + timestamp: `2026-04-06T00:0${minute}:00.000Z`, + content: [{ + type: 'tool_use', + id: toolUseId, + name: 'Bash', + input: { command: 'bun run dev', description: `Start ${taskId}`, run_in_background: true }, + }], + }, + { + id: `${toolUseId}-result`, + type: 'tool_result', + timestamp: `2026-04-06T00:0${minute}:01.000Z`, + content: [{ + type: 'tool_result', + tool_use_id: toolUseId, + content: `Command running in background with ID: ${taskId}. Output is being written to: /tmp/${taskId}.output`, + }], + }, + ] + const taskStop = ( + toolUseId: string, + taskId: string, + minute: number, + isError = false, + ): MessageEntry[] => [ + { + id: `${toolUseId}-use`, + type: 'assistant', + timestamp: `2026-04-06T00:0${minute}:00.000Z`, + content: [{ type: 'tool_use', id: toolUseId, name: 'TaskStop', input: { task_id: taskId } }], + }, + { + id: `${toolUseId}-result`, + type: 'tool_result', + timestamp: `2026-04-06T00:0${minute}:01.000Z`, + content: [{ + type: 'tool_result', + tool_use_id: toolUseId, + // The CLI records the stop as a JSON string and leaves no task-notification. + content: isError + ? 'No task found with ID: ' + taskId + : JSON.stringify({ + message: `Successfully stopped task: ${taskId} (bun run dev)`, + task_id: taskId, + task_type: 'local_bash', + command: 'bun run dev', + }), + ...(isError ? { is_error: true } : {}), + }], + }, + ] + + const restored = reconstructRunActivityFromTranscript([ + ...shellStart('shell-tool-stopped', 'stopped-task', 0), + ...shellStart('shell-tool-live', 'live-task', 1), + ...taskStop('stop-tool-1', 'stopped-task', 2), + ...taskStop('stop-tool-2', 'live-task', 3, true), + ...taskStop('stop-tool-3', 'unknown-task', 4), + ]) + + expect(restored.backgroundAgentTasks['stopped-task']).toMatchObject({ + taskId: 'stopped-task', + toolUseId: 'shell-tool-stopped', + status: 'stopped', + description: 'Start stopped-task', + taskType: 'local_bash', + }) + expect(restored.backgroundAgentTasks['live-task']?.status).toBe('running') + expect(restored.backgroundAgentTasks['unknown-task']).toBeUndefined() + expect(hasRunningBackgroundTasks({ + 'stopped-task': restored.backgroundAgentTasks['stopped-task']!, + })).toBe(false) + }) + it('recognizes manually backgrounded PowerShell from structured tool output', () => { const restored = reconstructRunActivityFromTranscript([ { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 37a45984..18a03b22 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -6990,6 +6990,7 @@ function reconstructBackgroundShellTasks( messages: MessageEntry[], ): Record { const shellToolUses = new Map() + const taskStopInputs = new Map>() let tasks: Record = {} for (const message of messages) { @@ -6999,11 +7000,12 @@ function reconstructBackgroundShellTasks( ) continue for (const block of message.content as AssistantHistoryBlock[]) { - if ( - block.type !== 'tool_use' || - !block.id || - !BACKGROUND_SHELL_TOOL_NAMES.has(block.name ?? '') - ) continue + if (block.type !== 'tool_use' || !block.id) continue + if (TASK_STOP_TOOL_NAMES.has(block.name ?? '')) { + taskStopInputs.set(block.id, readRecord(block.input) ?? {}) + continue + } + if (!BACKGROUND_SHELL_TOOL_NAMES.has(block.name ?? '')) continue const input = readRecord(block.input) ?? {} const description = readNonEmptyString(input, 'description', 'command') if (!description) continue @@ -7024,6 +7026,22 @@ function reconstructBackgroundShellTasks( for (const block of message.content as UserHistoryBlock[]) { if (block.type !== 'tool_result' || !block.tool_use_id) continue + const taskStopInput = taskStopInputs.get(block.tool_use_id) + if (taskStopInput) { + // A TaskStop leaves no task-notification behind, so its own result is + // the only record that a restored shell stopped running. + if (block.is_error) continue + const output = parseJsonRecord(block.content) ?? readRecord(message.toolUseResult) ?? {} + const taskId = readNonEmptyString(output, 'task_id', 'taskId') ?? + readNonEmptyString(taskStopInput, 'task_id', 'taskId', 'shell_id', 'shellId') + if (!taskId || !tasks[taskId]) continue + tasks = upsertBackgroundAgentTask(tasks, { + taskId, + status: 'stopped', + summary: readNonEmptyString(output, 'message'), + }, transcriptTimestamp(message.timestamp)) + continue + } const shellToolUse = shellToolUses.get(block.tool_use_id) if (!shellToolUse) continue const taskId = shellBackgroundTaskIdFromResult( diff --git a/src/server/__tests__/task-notification-persistence.test.ts b/src/server/__tests__/task-notification-persistence.test.ts index 9d67d9ba..fe266114 100644 --- a/src/server/__tests__/task-notification-persistence.test.ts +++ b/src/server/__tests__/task-notification-persistence.test.ts @@ -231,6 +231,101 @@ describe('background task notification persistence', () => { }]) }) + it('restores terminal notifications the CLI recorded as queued attachments or queue operations', async () => { + const sessionId = crypto.randomUUID() + const projectDir = path.join(configDir, 'projects', '-tmp-attachment-notification') + const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`) + await fs.mkdir(projectDir, { recursive: true }) + const notificationXml = (taskId: string, toolUseId: string, status: string) => + `\n${taskId}\n${toolUseId}\n/tmp/tasks/${taskId}.output\n${status}\nBackground command "Install desktop dependencies" ${status} (exit code 0)\n` + // A notification drained mid-turn is written as an attachment, not a user turn + // (official CLI 2.1.288 shape); its prompt may also be a content-block array. + const entries = [ + { + type: 'queue-operation', + operation: 'enqueue', + timestamp: '2026-10-05T10:52:39.100Z', + sessionId, + content: notificationXml('bdlrow0cg', 'toolu_bash_1', 'completed'), + }, + { + type: 'attachment', + uuid: crypto.randomUUID(), + timestamp: '2026-10-05T10:52:39.100Z', + attachment: { + type: 'queued_command', + prompt: notificationXml('bdlrow0cg', 'toolu_bash_1', 'completed'), + commandMode: 'task-notification', + origin: { kind: 'task-notification', producer: 'session-task' }, + }, + }, + { + type: 'attachment', + uuid: crypto.randomUUID(), + timestamp: '2026-10-05T11:00:00.000Z', + attachment: { + type: 'queued_command', + prompt: [{ type: 'text', text: notificationXml('b2', 'toolu_bash_2', 'killed') }], + commandMode: 'task-notification', + }, + }, + { + // Queued but never delivered: the session exited before draining it. + type: 'queue-operation', + operation: 'enqueue', + timestamp: '2026-10-05T11:02:00.000Z', + sessionId, + content: notificationXml('b3', 'toolu_bash_3', 'failed'), + }, + { + type: 'queue-operation', + operation: 'remove', + timestamp: '2026-10-05T11:03:00.000Z', + sessionId, + }, + { + // A prompt the user typed while a turn was running stays a prompt. + type: 'attachment', + uuid: crypto.randomUUID(), + timestamp: '2026-10-05T11:01:00.000Z', + attachment: { + type: 'queued_command', + prompt: notificationXml('typed', 'toolu_typed', 'completed'), + commandMode: 'prompt', + }, + }, + ] + await fs.writeFile(transcriptPath, `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`, 'utf8') + + const service = new SessionService() + expect(await service.getSessionTaskNotifications(sessionId)).toEqual([ + { + taskId: 'bdlrow0cg', + toolUseId: 'toolu_bash_1', + status: 'completed', + summary: 'Background command "Install desktop dependencies" completed (exit code 0)', + outputFile: '/tmp/tasks/bdlrow0cg.output', + timestamp: '2026-10-05T10:52:39.100Z', + }, + { + taskId: 'b2', + toolUseId: 'toolu_bash_2', + status: 'stopped', + summary: 'Background command "Install desktop dependencies" killed (exit code 0)', + outputFile: '/tmp/tasks/b2.output', + timestamp: '2026-10-05T11:00:00.000Z', + }, + { + taskId: 'b3', + toolUseId: 'toolu_bash_3', + status: 'failed', + summary: 'Background command "Install desktop dependencies" failed (exit code 0)', + outputFile: '/tmp/tasks/b3.output', + timestamp: '2026-10-05T11:02:00.000Z', + }, + ]) + }) + it('restores workflow run identity from task-notification transcript turns', async () => { const sessionId = crypto.randomUUID() const projectDir = path.join(configDir, 'projects', '-tmp-workflow-notification') diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index f2b04b7f..99fcd99e 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -3927,6 +3927,7 @@ describe('WebSocket handler session isolation', () => { const sessionId = `stop-background-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({}) + spyOn(conversationService, 'hasSession').mockReturnValue(true) handleWebSocket.open(ws) handleWebSocket.message(ws, JSON.stringify({ @@ -3945,6 +3946,7 @@ describe('WebSocket handler session isolation', () => { const sessionId = `stop-background-failed-${crypto.randomUUID()}` const ws = makeClientSocket(sessionId) spyOn(conversationService, 'requestControl').mockRejectedValue(new Error('Task is not running')) + spyOn(conversationService, 'hasSession').mockReturnValue(true) handleWebSocket.open(ws) handleWebSocket.message(ws, JSON.stringify({ @@ -3969,6 +3971,7 @@ describe('WebSocket handler session isolation', () => { const ws = makeClientSocket(sessionId) spyOn(conversationService, 'requestControl') .mockRejectedValue(new Error('No task found with ID: bash-task-1')) + spyOn(conversationService, 'hasSession').mockReturnValue(true) handleWebSocket.open(ws) handleWebSocket.message(ws, JSON.stringify({ @@ -4032,6 +4035,39 @@ describe('WebSocket handler session isolation', () => { expect(activeBackgroundTaskIds.get(sessionId)?.has('bash-evicted-1') ?? false).toBe(false) }) + it('converges a history-only running task to stopped when no CLI process exists', async () => { + // A session run by an exited (or external) CLI has no process to ask, so a + // task its history still shows as running is stopped locally, not reported + // as "CLI session is not running". + const sessionId = `stop-background-no-cli-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + spyOn(conversationService, 'hasSession').mockReturnValue(false) + const requestControl = spyOn(conversationService, 'requestControl') + .mockRejectedValue(new Error('CLI session is not running')) + const append = spyOn(sessionService, 'appendSessionTaskNotification').mockResolvedValue() + handleWebSocket.open(ws) + ws.sent.length = 0 + + handleWebSocket.message(ws, JSON.stringify({ + type: 'stop_background_task', + taskId: 'bdlrow0cg', + })) + await flushMicrotasks() + + expect(requestControl).not.toHaveBeenCalled() + expect(append).toHaveBeenCalledWith(sessionId, expect.objectContaining({ + taskId: 'bdlrow0cg', + status: 'stopped', + })) + const sent = ws.sent.map((payload) => JSON.parse(payload)) + expect(sent.some((payload) => payload.type === 'background_task_stop_failed')).toBe(false) + expect(sent).toContainEqual(expect.objectContaining({ + type: 'system_notification', + subtype: 'task_notification', + data: expect.objectContaining({ task_id: 'bdlrow0cg', status: 'stopped' }), + })) + }) + it('rejects malformed background task ids without throwing from the async handler', async () => { const ws = makeClientSocket(`stop-background-invalid-${crypto.randomUUID()}`) const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 26630ac4..0f24f4fe 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -2153,6 +2153,36 @@ export class SessionService { } } + // A notification the CLI drains while a turn is running is recorded as a + // queued_command attachment instead of a user turn, and one that was queued + // but never delivered (e.g. the session exited first) only as a + // queue-operation. For a session the desktop never ran, these are the only + // records that the task finished. + private parseQueuedTaskNotification( + entry: RawEntry, + ): SessionTaskNotification | null { + if (entry.type === 'queue-operation') { + return this.parseTaskNotificationContent(entry.content, entry.timestamp) + } + if (entry.type !== 'attachment') return null + const attachment = entry.attachment + if ( + !attachment || + typeof attachment !== 'object' || + Array.isArray(attachment) + ) { + return null + } + const record = attachment as Record + if ( + record.type !== 'queued_command' || + record.commandMode === 'prompt' + ) { + return null + } + return this.parseTaskNotificationContent(record.prompt, entry.timestamp) + } + private parsePersistedTaskNotification( value: unknown, timestamp?: string, @@ -5363,7 +5393,7 @@ export class SessionService { const entries = await this.readTargetedJsonlEntries( found, - ['user', PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE], + ['user', 'attachment', 'queue-operation', PERSISTED_TASK_NOTIFICATION_ENTRY_TYPE], ) ?? await this.readJsonlFile(found.filePath) return this.taskNotificationsFromEntries(entries) } @@ -5377,7 +5407,7 @@ export class SessionService { ? this.parsePersistedTaskNotification(entry.taskNotification, entry.timestamp) : entry.message?.role === 'user' ? this.parseTaskNotificationContent(entry.message.content, entry.timestamp) - : null + : this.parseQueuedTaskNotification(entry) if (notification) { notifications.set(sessionTaskNotificationIdentity(notification), notification) } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 1b8fe370..faa98875 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -2496,6 +2496,13 @@ async function requestStopBackgroundTask( await emitAuthoritativeNonAgentStopped(sessionId, tracked) return } + // With no CLI process, nothing is running the task any more (it only survives + // in the history of an exited or external CLI run), so Stop converges it to a + // terminal record instead of failing with "CLI session is not running". + if (!conversationService.hasSession(sessionId)) { + await convergeEvictedBackgroundTaskStop(sessionId, taskId) + return + } if (tracked) tracked.stopRequested = true try { const response = await conversationService.requestControl(sessionId, {