import assert from 'node:assert/strict' import { mkdtemp, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { SubagentScheduler, type SubagentWorker } from '../src/main/subagents/scheduler' import { executeTurnTool, type ConversationToolContext } from '../src/main/agent/conversation-tools' import { toolSpecs } from '../src/main/agent/tools' import type { CapabilityToolSnapshot } from '../src/main/capabilities/runtime' import type { ChatEvent, ChatMessage, ToolCallRecord } from '../src/shared/types' import { applyAssistantEvent } from '../src/shared/message-parts' import { getRevertChanges } from '../src/shared/revert' import { assistantWireHistory } from '../src/main/agent/history' const root = await mkdtemp(join(tmpdir(), 'ycode-agents-')) const checks: string[] = [] function check(label: string, condition: unknown): void { assert.ok(condition, label); checks.push(label); console.log('PASS ' + label) } const pause = (ms = 10): Promise => new Promise(done => setTimeout(done, ms)) const base: CapabilityToolSnapshot = { specs: [], execute: async () => undefined } function setup(enabled = true, hold = true, timeoutMs = 2000, resultText?: string) { const id = Math.random().toString(36), controller = new AbortController(), events: ChatEvent[] = [] const policies = new Map(), prompts: string[] = [], release = new Map void>() let current = 0, peak = 0, disposed = 0 const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'selected-model', enabled, signal: controller.signal, emit: event => events.push(event), capabilities: base, timeoutMs, worker: async (task, policy): Promise => { policies.set(task.id, policy) return { async run(prompt, signal, emit) { current++; peak = Math.max(peak, current); prompts.push(prompt) emit({ runId: 'child', type: 'usage', usage: { inputTokens: 20, outputTokens: 5, totalTokens: 25 } }) try { if (hold) await new Promise((done, reject) => { const onAbort = (): void => { signal.removeEventListener('abort', onAbort); reject(Object.assign(new Error('cancelled'), { name: 'AbortError' })) } signal.addEventListener('abort', onAbort, { once: true }) release.set(task.id, () => { signal.removeEventListener('abort', onAbort); done() }) if (signal.aborted) onAbort() }) emit({ runId: 'child', type: 'text', delta: 'RESULT:' + task.name }) return { content: resultText ?? 'RESULT:' + task.name, tools: [] } } finally { current-- } }, async dispose() { disposed++ } } } }) const ctx: ConversationToolContext = { runId: id, toolCallId: 'test-call', sessionId: 'parent-' + id, workspaceRoot: root, permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: event => events.push(event) } const call = (name: string, args: Record = {}) => executeTurnTool(name, args, ctx, scheduler.tools) const spawn = async (name: string, extra = {}) => { const result = await call('spawn_agent', { name, task: '独立调查并给出验收结果', ...extra }) assert.equal(result.ok, true, result.content); return JSON.parse(result.content) as { id: string; status: string } } return { scheduler, controller, ctx, call, spawn, events, policies, prompts, release, get peak() { return peak }, get current() { return current }, get disposed() { return disposed } } } { const state = setup(false) check('disabled switch hides spawn and rejects a direct stale-name invocation', !state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent') && !(await state.call('spawn_agent', { name: 'x', task: 'x' })).ok) state.scheduler.setEnabled(true) check('enabling immediately exposes real scheduling tools', state.scheduler.tools.specs.some(tool => tool.function.name === 'spawn_agent')) check('plan mode cannot bypass the scheduling mode gate', !(await executeTurnTool('spawn_agent', { name: 'x', task: 'x' }, { ...state.ctx, agentMode: 'plan' }, state.scheduler.tools)).ok) const spawned = await Promise.all([1, 2, 3, 4, 5, 6].map(i => state.spawn('任务' + i))) await pause() check('six tasks use five workers with the sixth queued', state.peak === 5 && JSON.parse((await state.call('list_agents')).content).filter((view: { status: string }) => view.status === 'queued').length === 1) const stranger = setup() check('cross-turn agent identifiers cannot be read or cancelled', !(await stranger.call('cancel_agent', { id: spawned[0].id })).ok) await stranger.spawn('跨会话排队') check('the concurrency budget also applies across root conversations', stranger.current === 0) await stranger.scheduler.close() state.scheduler.setEnabled(false) await state.scheduler.beforeFinish() check('disabling cancels running and queued children without aborting the parent', !state.controller.signal.aborted && JSON.parse((await state.call('list_agents')).content).every((view: { status: string }) => view.status === 'cancelled')) check('disabled switch also denies follow-up messages', !(await state.call('send_agent_message', { id: spawned[0].id, message: '继续' })).ok) await state.scheduler.close() check('cleanup disposes the five created child runtimes', state.disposed === 5) } { const state = setup(true, false, 2000, '\u0000'.repeat(60000)) await state.spawn('长结果') await pause() const result = await state.call('wait_agent', { timeoutMs: 1000 }) check('large escaped results remain bounded complete JSON', result.content.length <= 30000 && JSON.parse(result.content)[0].truncated) await state.scheduler.close() } { const state = setup() const writer = await state.spawn('写文件', { access: 'write', files: ['owned.txt'] }) check('write declarations reject directories, escapes and glob patterns', !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['.'] })).ok && !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['../escape.txt'] })).ok && !(await state.call('spawn_agent', { name: 'bad', task: 'x', access: 'write', files: ['*.ts'] })).ok) await pause() const policy = state.policies.get(writer.id)! const workerCtx = { ...state.ctx, runId: 'worker', sessionId: 'child', toolCallId: 'child-write' } check('child schemas hide commands, browsers and nested scheduling', policy.filterSpecs!(toolSpecs()).every(tool => ['list_dir', 'read_file', 'write_file', 'edit_file'].includes(tool.function.name))) check('unlisted shell and nested spawn calls are rejected by the host', !(await executeTurnTool('execute_command', { command: 'echo forbidden' }, workerCtx, policy)).ok && !(await executeTurnTool('spawn_agent', { name: 'nested', task: 'nested' }, workerCtx, policy)).ok) check('writer cannot modify another file', !(await executeTurnTool('write_file', { path: 'outside.txt', content: 'no' }, workerCtx, policy)).ok) check('same-file sibling assignment is denied', !(await state.call('spawn_agent', { name: '冲突', task: 'x', access: 'write', files: ['owned.txt'] })).ok) const blockedWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent' }) check('parent write to a child-leased file is still denied', !blockedWrite.ok) check('the denial names the file and the next step instead of blaming another task', blockedWrite.content.includes('子智能体') && blockedWrite.content.includes('owned.txt')) // 同一回合里并发发出的命令必须都能跑完:以前后到的一条会被判成"文件正由其他任务修改"。 const [slow, sibling] = await Promise.all([ state.call('execute_command', { command: 'ping -n 2 127.0.0.1 >nul && echo first' }), state.call('execute_command', { command: 'echo second' }) ]) check('same-round shell calls both succeed while a child holds a file lease', slow.ok && sibling.ok && slow.content.includes('first') && sibling.content.includes('second')) const childWrite = await executeTurnTool('write_file', { path: 'owned.txt', content: 'child' }, workerCtx, policy) check('declared full-access child file write really reaches disk', childWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'child') state.release.get(writer.id)!() await state.call('wait_agent', { ids: [writer.id], timeoutMs: 1000 }) const parentWrite = await state.call('write_file', { path: 'owned.txt', content: 'parent-after' }) check('file lease is released after child completion', parentWrite.ok && await readFile(join(root, 'owned.txt'), 'utf8') === 'parent-after') const childTool: ToolCallRecord = { id: 'child-write', name: 'write_file', status: 'ok', args: {}, argsRaw: '{}', result: childWrite.data, metadata: childWrite.metadata } const parentTool: ToolCallRecord = { ...childTool, id: 'parent-write', result: parentWrite.data, metadata: parentWrite.metadata } const view = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent') assert.ok(view?.type === 'part' && view.part.type === 'subagent') const messages: ChatMessage[] = [{ id: 'user', role: 'user', content: 'change', createdAt: 0 }, { id: 'assistant', role: 'assistant', content: '', createdAt: 1, tools: [parentTool], parts: [{ type: 'subagent', agent: { ...view.part.agent, tools: [childTool] } }], status: 'done' }] const changes = getRevertChanges(messages, 'user') check('rewind includes child writes before later parent writes', changes.length === 1 && changes[0].before === null && changes[0].after === 'parent-after') await state.scheduler.close() } { // 同一回合里并发发出的未知足迹工具(命令、连接器等)必须排队串行,而不是互相判成文件冲突。 const id = 'root-queue-' + Math.random().toString(36).slice(2) const controller = new AbortController() const started: string[] = [], finished: string[] = [] const gates = new Map void>() let active = 0, peak = 0 const tool = (name: string) => ({ type: 'function', function: { name, description: '排队用例的假工具', parameters: { type: 'object', properties: {} } } }) const capabilities: CapabilityToolSnapshot = { specs: [tool('custom_command'), tool('write_file')], async execute(_name, args) { const key = String((args as { key?: unknown; path?: unknown }).key ?? (args as { path?: unknown }).path ?? '') started.push(key); active++; peak = Math.max(peak, active) await new Promise(done => gates.set(key, done)) active--; finished.push(key) return { ok: true, content: 'done:' + key } } } const scheduler = new SubagentScheduler({ runId: id, workspaceRoot: root, modelName: 'queue-model', enabled: true, signal: controller.signal, emit: () => {}, capabilities, worker: async () => { throw new Error('排队用例不创建子智能体运行时') } }) const ctx: ConversationToolContext = { runId: id, toolCallId: 'queue', sessionId: 'parent-' + id, workspaceRoot: root, permissionMode: 'full-access', agentMode: 'craft', signal: controller.signal, emit: () => {} } const run = (key: string) => executeTurnTool('custom_command', { key }, ctx, scheduler.tools) const write = (path: string, context = ctx) => executeTurnTool('write_file', { path }, context, scheduler.tools) const startedKey = async (count: number): Promise => { for (let i = 0; i < 200 && started.length < count; i++) await pause(5) return started[count - 1] } const waitForKey = async (key: string): Promise => { for (let i = 0; i < 200 && !started.includes(key); i++) await pause(5) } const calls = [run('a'), run('b'), run('c')] const first = await startedKey(1) check('sibling root tools with unknown footprints are serialized, not rejected', started.length === 1 && peak === 1) gates.get(first)!() const second = await startedKey(2) check('the next sibling starts by itself once the holder finishes', started.length === 2 && peak === 1) gates.get(second)!() gates.get(await startedKey(3))!() const results = await Promise.all(calls) check('every concurrently issued root call succeeds exactly once in first-come order', results.every(result => result.ok) && new Set(results.map(result => result.content)).size === 3 && finished.length === 3 && peak === 1) // 父写文件期间到达的 spawn_agent 不能声明同一文件:父→子方向也必须互斥。 const pendingWrite = write('parent.txt') await waitForKey('parent.txt') const late = await executeTurnTool('spawn_agent', { name: 'late', task: 'same file', access: 'write', files: ['parent.txt'] }, ctx, scheduler.tools) check('a child cannot declare the file the parent is writing right now', !late.ok && late.content.includes('主智能体')) gates.get('parent.txt')!() check('the pending parent write completes normally', (await pendingWrite).ok) // 队首被取消后,后面与持有者不冲突的等待者必须立刻接续。 const blocker = write('block.txt') await waitForKey('block.txt') const headController = new AbortController() const head = write('block.txt', { ...ctx, signal: headController.signal }) await pause(50) const tail = write('queue-only.txt') await pause(30) check('a conflicting waiter holds the queue and an unrelated one waits behind it', !started.includes('queue-only.txt')) headController.abort() const headRejection = await head.then(() => undefined, (error: Error) => error) await waitForKey('queue-only.txt') check('cancelling the head lets the next non-conflicting waiter start at once', headRejection?.name === 'AbortError' && started.includes('queue-only.txt')) gates.get('queue-only.txt')!() await tail gates.get('block.txt')!() await blocker const holder = run('hold') await waitForKey('hold') const queued = run('waiting') await pause(30) check('a sibling arriving while the footprint is held waits instead of failing', !started.includes('waiting')) controller.abort() const rejection = await queued.then(() => undefined, (error: Error) => error) check('a queued root call is cancelled instead of hanging when the turn aborts', rejection?.name === 'AbortError') gates.get('hold')!() await holder await scheduler.close() } { const state = setup() const reader = await state.spawn('只读') await pause() const policy = state.policies.get(reader.id)! check('read-only child cannot write even with inherited full-access permission', !(await executeTurnTool('write_file', { path: 'read-only.txt', content: 'no' }, { ...state.ctx, runId: 'reader' }, policy)).ok) const after = state.scheduler.beforeFinish() state.release.get(reader.id)!() check('parent finalization waits and supplies unread child results for synthesis', (await after)?.includes('RESULT:只读')) check('collected results do not create an infinite finalization loop', await state.scheduler.beforeFinish() === undefined) const part = state.events.findLast(event => event.type === 'part' && event.part.type === 'subagent') assert.ok(part?.type === 'part') check('HTTP cold history preserves child results as explicitly labeled task data', JSON.stringify(assistantWireHistory({ id: 'history', role: 'assistant', content: '', createdAt: 0, parts: [part.part] })).includes('RESULT:只读')) const follow = await state.call('send_agent_message', { id: reader.id, message: '补充核对' }) await pause() state.release.get(reader.id)!() await state.call('wait_agent', { ids: [reader.id], timeoutMs: 1000 }) check('completed child keeps its runtime for follow-up instructions', follow.ok && state.prompts.length === 2 && state.prompts[1] === '补充核对') await state.scheduler.close() } { const state = setup() await state.spawn('取消测试') await pause() state.controller.abort() await state.scheduler.close() check('parent cancellation ends all children and releases their runtimes', state.current === 0 && state.disposed === 1) } { const state = setup(true, true, 50) await state.spawn('超时') await pause(100) const result = JSON.parse((await state.call('list_agents')).content) check('child execution timeout reaches a terminal state', result[0].status === 'cancelled') await state.scheduler.close() } { const state = setup() for (let i = 0; i < 16; i++) await state.spawn('有界任务' + i) check('task creation is bounded per root turn', !(await state.call('spawn_agent', { name: 'overflow', task: 'overflow' })).ok) const first = state.events.find(event => event.type === 'part' && event.part.type === 'subagent') assert.ok(first?.type === 'part' && first.part.type === 'subagent') let message: ChatMessage = { id: 'recover', role: 'assistant', content: '', createdAt: 0 } message = applyAssistantEvent(message, first) message = applyAssistantEvent(message, { ...first, part: { type: 'subagent', agent: { ...first.part.agent, status: 'running' } } }) check('streaming agent snapshots upsert in place', message.parts?.length === 1) message = applyAssistantEvent(message, { runId: state.ctx.runId, type: 'cancelled' }) check('cold interrupted history never leaves a child shown as running', message.parts?.[0].type === 'subagent' && message.parts[0].agent.status === 'cancelled') await state.scheduler.close() } await writeFile(join(root, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2)) console.log('SUBAGENT_REGRESSION_PASS ' + JSON.stringify({ checks: checks.length, directory: root }))