test: 添加 subagent 僵死场景相关测试用例

覆盖 subagent 生命周期关键模块的零覆盖函数:
- messageQueueManager: 扩展队列操作测试(enqueue/dequeue/优先级排序)
- queueProcessor: 测试 subagent 通知过滤和批量处理
- LocalAgentTask: 测试状态转换、通知防重、进度追踪
- task/framework: 测试 updateTaskState、registerTask、evictTerminalTask

共 66 个测试用例,135 个断言,全部通过。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-28 15:36:54 +08:00
parent 327658979a
commit 2bad8df5d7
4 changed files with 1045 additions and 24 deletions

View File

@@ -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: '<task-notification/>', 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')
})
})

View File

@@ -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: '<task1/>', mode: 'task-notification' } as any)
enqueuePendingNotification({ value: '<task2/>', 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(['<task1/>', '<task2/>'])
})
test('does not mix different modes in same batch', () => {
const executed: string[][] = []
enqueue({ value: 'hello', mode: 'prompt' } as any)
enqueuePendingNotification({ value: '<task/>', 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: '<task-notification>subagent result</task-notification>',
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: '<task-notification/>',
mode: 'task-notification',
agentId: 'agent-456',
} as any)
enqueuePendingNotification({
value: '<task-notification/>',
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: '<main-task/>', mode: 'task-notification' } as any)
enqueuePendingNotification({
value: '<sub-task/>',
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(['<main-task/>'])
// 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)
})
})

View File

@@ -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<string, any> = {}): 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<string, any> }
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)
})
})