From 2bad8df5d7c1e6328d6e6bf1dd073c46208c752e Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 28 Apr 2026 15:36:54 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20subagent=20?= =?UTF-8?q?=E5=83=B5=E6=AD=BB=E5=9C=BA=E6=99=AF=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 subagent 生命周期关键模块的零覆盖函数: - messageQueueManager: 扩展队列操作测试(enqueue/dequeue/优先级排序) - queueProcessor: 测试 subagent 通知过滤和批量处理 - LocalAgentTask: 测试状态转换、通知防重、进度追踪 - task/framework: 测试 updateTaskState、registerTask、evictTerminalTask 共 66 个测试用例,135 个断言,全部通过。 Co-Authored-By: Claude Opus 4.7 --- .../__tests__/LocalAgentTask.test.ts | 487 ++++++++++++++++++ .../__tests__/messageQueueManager.test.ts | 215 +++++++- src/utils/__tests__/queueProcessor.test.ts | 162 ++++++ src/utils/task/__tests__/framework.test.ts | 205 ++++++++ 4 files changed, 1045 insertions(+), 24 deletions(-) create mode 100644 src/tasks/LocalAgentTask/__tests__/LocalAgentTask.test.ts create mode 100644 src/utils/__tests__/queueProcessor.test.ts create mode 100644 src/utils/task/__tests__/framework.test.ts diff --git a/src/tasks/LocalAgentTask/__tests__/LocalAgentTask.test.ts b/src/tasks/LocalAgentTask/__tests__/LocalAgentTask.test.ts new file mode 100644 index 000000000..ad215c5a2 --- /dev/null +++ b/src/tasks/LocalAgentTask/__tests__/LocalAgentTask.test.ts @@ -0,0 +1,487 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { debugMock } from '../../../../tests/mocks/debug.js' +import { logMock } from '../../../../tests/mocks/log.js' + +// ─── Mocks ─── + +const noop = () => {} + +mock.module('src/utils/debug.ts', debugMock) +mock.module('src/utils/log.ts', logMock) + +mock.module('src/utils/sessionStorage.js', () => ({ + getAgentTranscriptPath: (id: string) => `/tmp/transcripts/${id}.jsonl`, + recordSidechainTranscript: async () => {}, + recordQueueOperation: noop, + writeAgentMetadata: async () => {}, +})) + +mock.module('src/utils/task/diskOutput.js', () => ({ + evictTaskOutput: noop, + getTaskOutputPath: (id: string) => `/tmp/output/${id}`, + initTaskOutputAsSymlink: async () => {}, + getTaskOutputDelta: async () => null, +})) + +// Capture enqueuePendingNotification calls for verification +const enqueuedNotifications: string[] = [] +mock.module('src/utils/messageQueueManager.js', () => ({ + enqueuePendingNotification: (cmd: any) => { + enqueuedNotifications.push(cmd.value) + }, +})) + +mock.module('src/bootstrap/state.js', () => ({ + getSdkAgentProgressSummariesEnabled: () => false, + getSessionId: () => 'test-session-001', + getProjectRoot: () => '/test/project', + getIsNonInteractiveSession: () => false, + addSlowOperation: noop, +})) + +mock.module('src/services/PromptSuggestion/speculation.js', () => ({ + abortSpeculation: noop, +})) + +const cleanupFns: (() => void)[] = [] +mock.module('src/utils/cleanupRegistry.js', () => ({ + registerCleanup: () => noop, +})) + +mock.module('src/utils/abortController.js', () => ({ + createAbortController: () => new AbortController(), + createChildAbortController: (parent: AbortController) => { + const ac = new AbortController() + parent.signal.addEventListener('abort', () => ac.abort()) + return ac + }, +})) + +mock.module('src/utils/task/sdkProgress.js', () => ({ + emitTaskProgress: noop, +})) + +mock.module('src/utils/sdkEventQueue.js', () => ({ + enqueueSdkEvent: noop, +})) + +mock.module('src/constants/xml.js', () => ({ + TASK_NOTIFICATION_TAG: 'task_notification', + TASK_ID_TAG: 'task_id', + TOOL_USE_ID_TAG: 'tool_use_id', + OUTPUT_FILE_TAG: 'output_file', + STATUS_TAG: 'status', + SUMMARY_TAG: 'summary', + WORKTREE_TAG: 'worktree', + WORKTREE_PATH_TAG: 'worktree_path', + WORKTREE_BRANCH_TAG: 'worktree_branch', + TASK_TYPE_TAG: 'task_type', +})) + +mock.module('src/services/analytics/index.js', () => ({ + logEvent: noop, + logEventAsync: async () => {}, + stripProtoFields: (v: any) => v, + attachAnalyticsSink: noop, + _resetForTesting: noop, + AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined, +})) + +mock.module('src/utils/collapseReadSearch.js', () => ({ + getToolSearchOrReadInfo: () => undefined, +})) + +// ─── Import after mocks ─── + +const { + createProgressTracker, + updateProgressFromMessage, + getProgressUpdate, + completeAgentTask, + failAgentTask, + killAsyncAgent, + enqueueAgentNotification, + registerAsyncAgent, + updateAgentProgress, + isLocalAgentTask, +} = await import('../LocalAgentTask.js') + +// ─── Helpers ─── + +type AppStateLike = { tasks: Record } +type SetAppStateLike = (f: (prev: AppStateLike) => AppStateLike) => void + +function createSetAppState(initial: AppStateLike = { tasks: {} }): { + setAppState: SetAppStateLike + getState: () => AppStateLike +} { + let state = initial + return { + setAppState: (f) => { + state = f(state) + }, + getState: () => state, + } +} + +function makeRunningTask(overrides: Record = {}): any { + return { + id: 'test-agent-001', + type: 'local_agent', + status: 'running', + description: 'Test agent', + agentId: 'test-agent-001', + prompt: 'do something', + agentType: 'general-purpose', + abortController: new AbortController(), + retrieved: false, + lastReportedToolCount: 0, + lastReportedTokenCount: 0, + isBackgrounded: true, + pendingMessages: [], + retain: false, + diskLoaded: false, + notified: false, + startTime: Date.now(), + outputFile: '/tmp/output/test-agent-001', + outputOffset: 0, + ...overrides, + } +} + +function makeAssistantMessage(usage: any, content: any[] = []): any { + return { + type: 'assistant', + message: { + usage, + content, + }, + } +} + +afterEach(() => { + enqueuedNotifications.length = 0 +}) + +// ─── Tests ─── + +describe('createProgressTracker', () => { + test('returns initial state with zero counts', () => { + const tracker = createProgressTracker() + expect(tracker.toolUseCount).toBe(0) + expect(tracker.latestInputTokens).toBe(0) + expect(tracker.cumulativeOutputTokens).toBe(0) + expect(tracker.recentActivities).toEqual([]) + }) +}) + +describe('updateProgressFromMessage', () => { + test('skips non-assistant messages', () => { + const tracker = createProgressTracker() + updateProgressFromMessage(tracker, { type: 'user', message: {} } as any) + expect(tracker.toolUseCount).toBe(0) + expect(tracker.latestInputTokens).toBe(0) + }) + + test('updates token counts from assistant message usage', () => { + const tracker = createProgressTracker() + const msg = makeAssistantMessage({ + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }) + updateProgressFromMessage(tracker, msg) + expect(tracker.latestInputTokens).toBe(150) // 100 + 20 + 30 + expect(tracker.cumulativeOutputTokens).toBe(50) + }) + + test('counts tool_use blocks and tracks recent activities', () => { + const tracker = createProgressTracker() + const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [ + { type: 'tool_use', name: 'Read', input: { file_path: '/foo.ts' } }, + { type: 'text', text: 'thinking...' }, + { type: 'tool_use', name: 'Write', input: { file_path: '/bar.ts' } }, + ]) + updateProgressFromMessage(tracker, msg) + expect(tracker.toolUseCount).toBe(2) + expect(tracker.recentActivities).toHaveLength(2) + expect(tracker.recentActivities[0]!.toolName).toBe('Read') + expect(tracker.recentActivities[1]!.toolName).toBe('Write') + }) + + test('caps recentActivities at 5', () => { + const tracker = createProgressTracker() + for (let i = 0; i < 7; i++) { + const msg = makeAssistantMessage({ input_tokens: 0, output_tokens: 0 }, [ + { type: 'tool_use', name: `Tool${i}`, input: {} }, + ]) + updateProgressFromMessage(tracker, msg) + } + expect(tracker.recentActivities).toHaveLength(5) + }) + + test('skips without usage', () => { + const tracker = createProgressTracker() + const msg = makeAssistantMessage(null) + updateProgressFromMessage(tracker, msg) + expect(tracker.latestInputTokens).toBe(0) + }) +}) + +describe('getProgressUpdate', () => { + test('returns correct progress snapshot', () => { + const tracker = createProgressTracker() + tracker.toolUseCount = 3 + tracker.latestInputTokens = 100 + tracker.cumulativeOutputTokens = 50 + tracker.recentActivities.push({ toolName: 'Read', input: {} }) + + const progress = getProgressUpdate(tracker) + expect(progress.toolUseCount).toBe(3) + expect(progress.tokenCount).toBe(150) + expect(progress.lastActivity).toBeDefined() + expect(progress.lastActivity!.toolName).toBe('Read') + }) + + test('returns undefined lastActivity when no activities', () => { + const tracker = createProgressTracker() + const progress = getProgressUpdate(tracker) + expect(progress.lastActivity).toBeUndefined() + }) +}) + +describe('completeAgentTask', () => { + test('transitions running task to completed', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask() }, + }) + + completeAgentTask( + { agentId: 'test-agent-001', content: [], totalToolUseCount: 0, totalDurationMs: 100 } as any, + setAppState as any, + ) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('completed') + expect(task.endTime).toBeDefined() + expect(task.evictAfter).toBeDefined() + }) + + test('no-op if task not running', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) }, + }) + + completeAgentTask( + { agentId: 'test-agent-001', content: [], totalToolUseCount: 0, totalDurationMs: 100 } as any, + setAppState as any, + ) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('completed') + }) +}) + +describe('failAgentTask', () => { + test('transitions running task to failed with error message', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask() }, + }) + + failAgentTask('test-agent-001', 'Stream idle timeout', setAppState as any) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('failed') + expect(task.error).toBe('Stream idle timeout') + expect(task.endTime).toBeDefined() + }) + + test('no-op if task not running', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ status: 'killed' }) }, + }) + + failAgentTask('test-agent-001', 'error', setAppState as any) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('killed') + expect(task.error).toBeUndefined() + }) +}) + +describe('killAsyncAgent', () => { + test('transitions running task to killed', () => { + const ac = new AbortController() + const cleanup = mock(() => {}) + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ abortController: ac, unregisterCleanup: cleanup }) }, + }) + + killAsyncAgent('test-agent-001', setAppState as any) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('killed') + expect(ac.signal.aborted).toBe(true) + expect(cleanup).toHaveBeenCalled() + expect(task.abortController).toBeUndefined() + }) + + test('no-op if task not running', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ status: 'completed' }) }, + }) + + killAsyncAgent('test-agent-001', setAppState as any) + + const task = getState().tasks['test-agent-001'] + expect(task.status).toBe('completed') + }) +}) + +describe('enqueueAgentNotification', () => { + test('enqueues completed notification with correct XML format', () => { + const { setAppState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ notified: false }) }, + }) + + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'refactor auth', + status: 'completed', + setAppState: setAppState as any, + finalMessage: 'Done!', + usage: { totalTokens: 5000, toolUses: 3, durationMs: 10000 }, + }) + + expect(enqueuedNotifications).toHaveLength(1) + expect(enqueuedNotifications[0]).toContain('') + expect(enqueuedNotifications[0]).toContain('test-agent-001') + expect(enqueuedNotifications[0]).toContain('completed') + expect(enqueuedNotifications[0]).toContain('Agent "refactor auth" completed') + expect(enqueuedNotifications[0]).toContain('Done!') + expect(enqueuedNotifications[0]).toContain('5000') + }) + + test('enqueues failed notification with error', () => { + const { setAppState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ notified: false }) }, + }) + + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'test', + status: 'failed', + error: 'Stream idle timeout', + setAppState: setAppState as any, + }) + + expect(enqueuedNotifications).toHaveLength(1) + expect(enqueuedNotifications[0]).toContain('failed') + expect(enqueuedNotifications[0]).toContain('Agent "test" failed: Stream idle timeout') + }) + + test('enqueues killed notification', () => { + const { setAppState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ notified: false }) }, + }) + + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'test', + status: 'killed', + setAppState: setAppState as any, + }) + + expect(enqueuedNotifications).toHaveLength(1) + expect(enqueuedNotifications[0]).toContain('killed') + expect(enqueuedNotifications[0]).toContain('Agent "test" was stopped') + }) + + test('prevents duplicate notifications', () => { + const { setAppState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ notified: false }) }, + }) + + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'test', + status: 'completed', + setAppState: setAppState as any, + }) + + // Second call — notified flag already set by first call + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'test', + status: 'completed', + setAppState: setAppState as any, + }) + + expect(enqueuedNotifications).toHaveLength(1) + }) + + test('skips if task already notified', () => { + const { setAppState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ notified: true }) }, + }) + + enqueueAgentNotification({ + taskId: 'test-agent-001', + description: 'test', + status: 'completed', + setAppState: setAppState as any, + }) + + expect(enqueuedNotifications).toHaveLength(0) + }) +}) + +describe('isLocalAgentTask', () => { + test('returns true for local_agent type', () => { + expect(isLocalAgentTask(makeRunningTask())).toBe(true) + }) + + test('returns false for other types', () => { + expect(isLocalAgentTask({ type: 'local_bash' })).toBe(false) + }) + + test('returns false for null/undefined', () => { + expect(isLocalAgentTask(null)).toBe(false) + expect(isLocalAgentTask(undefined)).toBe(false) + }) +}) + +describe('updateAgentProgress', () => { + test('updates progress while preserving summary', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ progress: { summary: 'Working on auth' } }) }, + }) + + updateAgentProgress( + 'test-agent-001', + { toolUseCount: 5, tokenCount: 1000, lastActivity: { toolName: 'Write', input: {} } }, + setAppState as any, + ) + + const task = getState().tasks['test-agent-001'] + expect(task.progress.toolUseCount).toBe(5) + expect(task.progress.tokenCount).toBe(1000) + expect(task.progress.summary).toBe('Working on auth') + }) + + test('no-op if task not running', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'test-agent-001': makeRunningTask({ status: 'completed', progress: {} }) }, + }) + + updateAgentProgress( + 'test-agent-001', + { toolUseCount: 5, tokenCount: 1000 }, + setAppState as any, + ) + + const task = getState().tasks['test-agent-001'] + expect(task.progress.toolUseCount).toBeUndefined() + }) +}) diff --git a/src/utils/__tests__/messageQueueManager.test.ts b/src/utils/__tests__/messageQueueManager.test.ts index 91b41ab14..f1977883e 100644 --- a/src/utils/__tests__/messageQueueManager.test.ts +++ b/src/utils/__tests__/messageQueueManager.test.ts @@ -1,30 +1,197 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { isSlashCommand } from '../messageQueueManager.js' +import { + clearCommandQueue, + dequeue, + dequeueAllMatching, + enqueue, + enqueuePendingNotification, + hasCommandsInQueue, + isSlashCommand, + peek, + resetCommandQueue, +} from '../messageQueueManager.js' + +// Reset module-level queue state between tests +beforeEach(() => { + resetCommandQueue() +}) + +afterEach(() => { + resetCommandQueue() +}) describe('messageQueueManager.isSlashCommand', () => { - test('treats normal slash commands as slash commands', () => { - expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true) - }) + test('treats normal slash commands as slash commands', () => { + expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true) + }) - test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => { - expect( - isSlashCommand({ - value: '/proactive', - mode: 'prompt', - skipSlashCommands: true, - bridgeOrigin: true, - } as any), - ).toBe(true) - }) + test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => { + expect( + isSlashCommand({ + value: '/proactive', + mode: 'prompt', + skipSlashCommands: true, + bridgeOrigin: true, + } as any), + ).toBe(true) + }) - test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => { - expect( - isSlashCommand({ - value: '/proactive', - mode: 'prompt', - skipSlashCommands: true, - } as any), - ).toBe(false) - }) + test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => { + expect( + isSlashCommand({ + value: '/proactive', + mode: 'prompt', + skipSlashCommands: true, + } as any), + ).toBe(false) + }) +}) + +describe('messageQueueManager.enqueue', () => { + test('adds command to queue with default next priority', () => { + enqueue({ value: 'hello', mode: 'prompt' } as any) + expect(hasCommandsInQueue()).toBe(true) + const cmd = dequeue() + expect(cmd).toBeDefined() + expect(cmd!.value).toBe('hello') + expect(cmd!.priority).toBe('next') + }) + + test('preserves explicit priority', () => { + enqueue({ value: 'urgent', mode: 'prompt', priority: 'now' } as any) + const cmd = dequeue() + expect(cmd!.priority).toBe('now') + }) +}) + +describe('messageQueueManager.enqueuePendingNotification', () => { + test('adds command with later priority', () => { + enqueuePendingNotification({ value: '', mode: 'task-notification' } as any) + const cmd = dequeue() + expect(cmd).toBeDefined() + expect(cmd!.priority).toBe('later') + expect(cmd!.mode).toBe('task-notification') + }) +}) + +describe('messageQueueManager.dequeue', () => { + test('returns undefined when queue empty', () => { + expect(dequeue()).toBeUndefined() + }) + + test('returns highest priority command', () => { + enqueuePendingNotification({ value: 'later-cmd', mode: 'task-notification' } as any) + enqueue({ value: 'next-cmd', mode: 'prompt' } as any) + enqueue({ value: 'now-cmd', mode: 'prompt', priority: 'now' } as any) + + const first = dequeue() + expect(first!.value).toBe('now-cmd') + + const second = dequeue() + expect(second!.value).toBe('next-cmd') + + const third = dequeue() + expect(third!.value).toBe('later-cmd') + }) + + test('FIFO within same priority', () => { + enqueue({ value: 'first', mode: 'prompt' } as any) + enqueue({ value: 'second', mode: 'prompt' } as any) + + expect(dequeue()!.value).toBe('first') + expect(dequeue()!.value).toBe('second') + }) + + test('respects filter parameter', () => { + enqueue({ value: 'prompt-cmd', mode: 'prompt' } as any) + enqueuePendingNotification({ value: 'task-cmd', mode: 'task-notification' } as any) + + // Filter to only task-notification commands + const cmd = dequeue(c => c.mode === 'task-notification') + expect(cmd).toBeDefined() + expect(cmd!.value).toBe('task-cmd') + + // Prompt command should still be in queue + expect(hasCommandsInQueue()).toBe(true) + expect(dequeue()!.value).toBe('prompt-cmd') + }) +}) + +describe('messageQueueManager.peek', () => { + test('returns undefined when queue empty', () => { + expect(peek()).toBeUndefined() + }) + + test('returns highest priority without removing', () => { + enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any) + enqueue({ value: 'next', mode: 'prompt' } as any) + + expect(peek()!.value).toBe('next') + expect(hasCommandsInQueue()).toBe(true) + expect(dequeue()!.value).toBe('next') + }) +}) + +describe('messageQueueManager.dequeueAllMatching', () => { + test('removes all matching commands', () => { + enqueue({ value: 'a', mode: 'prompt' } as any) + enqueue({ value: 'b', mode: 'task-notification' } as any) + enqueue({ value: 'c', mode: 'task-notification' } as any) + + const matched = dequeueAllMatching(c => c.mode === 'task-notification') + expect(matched).toHaveLength(2) + expect(matched.map(c => c.value)).toEqual(['b', 'c']) + + // Remaining command should still be in queue + expect(dequeue()!.value).toBe('a') + }) + + test('returns empty array when no matches', () => { + enqueue({ value: 'a', mode: 'prompt' } as any) + const matched = dequeueAllMatching(c => c.mode === 'bash') + expect(matched).toHaveLength(0) + expect(hasCommandsInQueue()).toBe(true) + }) + + test('returns empty array when queue empty', () => { + const matched = dequeueAllMatching(() => true) + expect(matched).toHaveLength(0) + }) +}) + +describe('messageQueueManager.clearCommandQueue', () => { + test('removes all commands', () => { + enqueue({ value: 'a', mode: 'prompt' } as any) + enqueue({ value: 'b', mode: 'prompt' } as any) + expect(hasCommandsInQueue()).toBe(true) + + clearCommandQueue() + expect(hasCommandsInQueue()).toBe(false) + }) + + test('no-op on empty queue', () => { + clearCommandQueue() + expect(hasCommandsInQueue()).toBe(false) + }) +}) + +describe('messageQueueManager priority ordering', () => { + test('now dequeued before next and later', () => { + enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any) + enqueue({ value: 'next', mode: 'prompt' } as any) + enqueue({ value: 'now', mode: 'prompt', priority: 'now' } as any) + + expect(dequeue()!.value).toBe('now') + expect(dequeue()!.value).toBe('next') + expect(dequeue()!.value).toBe('later') + }) + + test('next dequeued before later', () => { + enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any) + enqueue({ value: 'next', mode: 'prompt' } as any) + + expect(dequeue()!.value).toBe('next') + expect(dequeue()!.value).toBe('later') + }) }) diff --git a/src/utils/__tests__/queueProcessor.test.ts b/src/utils/__tests__/queueProcessor.test.ts new file mode 100644 index 000000000..763240547 --- /dev/null +++ b/src/utils/__tests__/queueProcessor.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { + resetCommandQueue, + enqueue, + enqueuePendingNotification, +} from '../messageQueueManager.js' +import { hasQueuedCommands, processQueueIfReady } from '../queueProcessor.js' + +beforeEach(() => { + resetCommandQueue() +}) + +afterEach(() => { + resetCommandQueue() +}) + +describe('processQueueIfReady', () => { + test('returns processed:false when queue empty', () => { + const result = processQueueIfReady({ + executeInput: async () => {}, + }) + expect(result.processed).toBe(false) + }) + + test('processes single slash command individually', () => { + const executed: string[][] = [] + enqueue({ value: '/help', mode: 'prompt' } as any) + + const result = processQueueIfReady({ + executeInput: async cmds => { + executed.push(cmds.map(c => c.value as string)) + }, + }) + + expect(result.processed).toBe(true) + expect(executed).toHaveLength(1) + expect(executed[0]).toEqual(['/help']) + }) + + test('processes bash mode command individually', () => { + const executed: string[][] = [] + enqueue({ value: 'git status', mode: 'bash' } as any) + + const result = processQueueIfReady({ + executeInput: async cmds => { + executed.push(cmds.map(c => c.value as string)) + }, + }) + + expect(result.processed).toBe(true) + expect(executed).toHaveLength(1) + expect(executed[0]).toEqual(['git status']) + }) + + test('batches commands with same mode', () => { + const executed: string[][] = [] + enqueuePendingNotification({ value: '', mode: 'task-notification' } as any) + enqueuePendingNotification({ value: '', mode: 'task-notification' } as any) + + const result = processQueueIfReady({ + executeInput: async cmds => { + executed.push(cmds.map(c => c.value as string)) + }, + }) + + expect(result.processed).toBe(true) + expect(executed).toHaveLength(1) + expect(executed[0]).toEqual(['', '']) + }) + + test('does not mix different modes in same batch', () => { + const executed: string[][] = [] + enqueue({ value: 'hello', mode: 'prompt' } as any) + enqueuePendingNotification({ value: '', mode: 'task-notification' } as any) + + const result = processQueueIfReady({ + executeInput: async cmds => { + executed.push(cmds.map(c => c.value as string)) + }, + }) + + expect(result.processed).toBe(true) + // Only the 'prompt' mode command should be processed (higher priority than task-notification) + expect(executed).toHaveLength(1) + expect(executed[0]).toEqual(['hello']) + + // The task-notification is still in queue + expect(hasQueuedCommands()).toBe(true) + }) + + test('skips commands with agentId set (subagent notifications)', () => { + // This simulates the v2.1.119 fix: subagent task-notification with agentId + // should not be processed by the main thread queue processor + enqueuePendingNotification({ + value: 'subagent result', + mode: 'task-notification', + agentId: 'agent-123', + } as any) + + const result = processQueueIfReady({ + executeInput: async () => {}, + }) + + // Should not process — it's a subagent notification + expect(result.processed).toBe(false) + }) + + test('returns processed:false when only subagent commands in queue', () => { + enqueuePendingNotification({ + value: '', + mode: 'task-notification', + agentId: 'agent-456', + } as any) + enqueuePendingNotification({ + value: '', + mode: 'task-notification', + agentId: 'agent-789', + } as any) + + const result = processQueueIfReady({ + executeInput: async () => {}, + }) + + expect(result.processed).toBe(false) + expect(hasQueuedCommands()).toBe(true) + }) + + test('processes main-thread command but skips subagent command', () => { + const executed: string[][] = [] + enqueuePendingNotification({ value: '', mode: 'task-notification' } as any) + enqueuePendingNotification({ + value: '', + mode: 'task-notification', + agentId: 'agent-123', + } as any) + + const result = processQueueIfReady({ + executeInput: async cmds => { + executed.push(cmds.map(c => c.value as string)) + }, + }) + + expect(result.processed).toBe(true) + expect(executed).toHaveLength(1) + expect(executed[0]).toEqual(['']) + + // Subagent command still in queue + expect(hasQueuedCommands()).toBe(true) + }) +}) + +describe('hasQueuedCommands', () => { + test('returns false when queue empty', () => { + expect(hasQueuedCommands()).toBe(false) + }) + + test('returns true when commands in queue', () => { + enqueue({ value: 'hello', mode: 'prompt' } as any) + expect(hasQueuedCommands()).toBe(true) + }) +}) diff --git a/src/utils/task/__tests__/framework.test.ts b/src/utils/task/__tests__/framework.test.ts new file mode 100644 index 000000000..8e5fe801b --- /dev/null +++ b/src/utils/task/__tests__/framework.test.ts @@ -0,0 +1,205 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { debugMock } from '../../../../tests/mocks/debug.js' + +// ─── Mocks ─── + +const noop = () => {} + +mock.module('src/utils/debug.ts', debugMock) + +const sdkEvents: any[] = [] +mock.module('src/utils/sdkEventQueue.js', () => ({ + enqueueSdkEvent: (event: any) => sdkEvents.push(event), +})) + +mock.module('src/utils/task/diskOutput.js', () => ({ + getTaskOutputPath: (id: string) => `/tmp/output/${id}`, + getTaskOutputDelta: async () => null, + evictTaskOutput: noop, + initTaskOutputAsSymlink: async () => {}, +})) + +mock.module('src/utils/messageQueueManager.js', () => ({ + enqueuePendingNotification: noop, +})) + +// ─── Import after mocks ─── + +const { updateTaskState, registerTask, evictTerminalTask, POLL_INTERVAL_MS, PANEL_GRACE_MS } = await import('../framework.js') + +// ─── Helpers ─── + +function makeTask(overrides: Record = {}): any { + return { + id: 'task-001', + type: 'local_agent' as const, + status: 'running' as const, + description: 'Test task', + startTime: Date.now(), + outputFile: '/tmp/output/task-001', + outputOffset: 0, + notified: false, + ...overrides, + } +} + +type AppStateLike = { tasks: Record } +type SetAppStateLike = (f: (prev: AppStateLike) => AppStateLike) => void + +function createSetAppState(initial: AppStateLike = { tasks: {} }): { + setAppState: SetAppStateLike + getState: () => AppStateLike +} { + let state = initial + return { + setAppState: (f) => { state = f(state) }, + getState: () => state, + } +} + +afterEach(() => { + sdkEvents.length = 0 +}) + +// ─── Tests ─── + +describe('updateTaskState', () => { + test('updates task in AppState', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'task-001': makeTask({ status: 'running' }) }, + }) + + updateTaskState('task-001', setAppState as any, (task: any) => ({ + ...task, + status: 'completed', + })) + + expect(getState().tasks['task-001'].status).toBe('completed') + }) + + test('returns same reference when updater returns same task (no-op)', () => { + const task = makeTask({ status: 'running' }) + const { setAppState, getState } = createSetAppState({ tasks: { 'task-001': task } }) + + updateTaskState('task-001', setAppState as any, (t: any) => t) + + // Should be the exact same reference + expect(getState().tasks['task-001']).toBe(task) + }) + + test('skips if task not found', () => { + const { setAppState, getState } = createSetAppState({ tasks: {} }) + + updateTaskState('nonexistent', setAppState as any, (t: any) => ({ + ...t, + status: 'completed', + })) + + // No crash, tasks unchanged + expect(Object.keys(getState().tasks)).toHaveLength(0) + }) +}) + +describe('registerTask', () => { + test('adds task to AppState.tasks', () => { + const { setAppState, getState } = createSetAppState() + + registerTask(makeTask(), setAppState as any) + + expect(getState().tasks['task-001']).toBeDefined() + expect(getState().tasks['task-001'].status).toBe('running') + }) + + test('emits SDK event for new task', () => { + const { setAppState } = createSetAppState() + + registerTask(makeTask(), setAppState as any) + + expect(sdkEvents).toHaveLength(1) + expect(sdkEvents[0].subtype).toBe('task_started') + expect(sdkEvents[0].task_id).toBe('task-001') + }) + + test('merges retain on re-register', () => { + const { setAppState, getState } = createSetAppState() + + // First registration + registerTask(makeTask({ retain: true }), setAppState as any) + + // Re-register (resume) + registerTask(makeTask({ retain: false }), setAppState as any) + + // retain should be preserved from first registration + expect(getState().tasks['task-001'].retain).toBe(true) + // Only one SDK event (re-register skips emit) + expect(sdkEvents).toHaveLength(1) + }) +}) + +describe('evictTerminalTask', () => { + test('removes terminal+notified task', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'task-001': makeTask({ status: 'completed', notified: true, evictAfter: Date.now() - 1 }) }, + }) + + evictTerminalTask('task-001', setAppState as any) + + expect(getState().tasks['task-001']).toBeUndefined() + }) + + test('skips if task not terminal', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'task-001': makeTask({ status: 'running', notified: true }) }, + }) + + evictTerminalTask('task-001', setAppState as any) + + expect(getState().tasks['task-001']).toBeDefined() + }) + + test('skips if task not notified', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { 'task-001': makeTask({ status: 'completed', notified: false }) }, + }) + + evictTerminalTask('task-001', setAppState as any) + + expect(getState().tasks['task-001']).toBeDefined() + }) + + test('skips if within evictAfter grace period', () => { + const { setAppState, getState } = createSetAppState({ + tasks: { + 'task-001': makeTask({ + status: 'completed', + notified: true, + evictAfter: Date.now() + 60000, // 60s in the future + retain: false, + }), + }, + }) + + evictTerminalTask('task-001', setAppState as any) + + expect(getState().tasks['task-001']).toBeDefined() + }) + + test('skips if task not found', () => { + const { setAppState, getState } = createSetAppState({ tasks: {} }) + + evictTerminalTask('nonexistent', setAppState as any) + + // No crash + expect(Object.keys(getState().tasks)).toHaveLength(0) + }) +}) + +describe('constants', () => { + test('POLL_INTERVAL_MS is 1000', () => { + expect(POLL_INTERVAL_MS).toBe(1000) + }) + + test('PANEL_GRACE_MS is 30000', () => { + expect(PANEL_GRACE_MS).toBe(30_000) + }) +})