import { describe, it, expect } from 'bun:test' import { mkdtempSync, writeFileSync, existsSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { ToolRegistry } from '../../src/tools/ToolRegistry.js' import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js' import { Scheduler } from '../../src/scheduler/Scheduler.js' import { MainAgent } from '../../src/agents/main/MainAgent.js' import { ContextAssembler } from '../../src/context/ContextAssembler.js' import { createNullEventIngestor } from '../../src/events/EventIngestor.js' function createRegistry(projectRoot: string): ToolRegistry { const registry = new ToolRegistry(projectRoot) new BuiltInToolRegistrar(registry).register_all(projectRoot) return registry } describe('Release critical gates', () => { it('built-in tool success envelopes use output, not content', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'air-tool-envelope-')) writeFileSync(join(projectRoot, 'sample.txt'), 'hello') const registry = createRegistry(projectRoot) const ctx = { session_id: 's', project_id: 'p', project_root: projectRoot, agent_id: 'a', agent_type: 'executor' as const } for (const [name, args] of [ ['fs.stat', { path: 'sample.txt' }], ['project.scan', { root: '.' }], ['cpp.detect', { project_root: projectRoot }], ['doctor.run', { scope: 'all' }], ] as Array<[string, Record]>) { const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx) expect(result.status).toBe('ok') expect(result.output).toBeDefined() expect((result as any).content).toBeUndefined() } }) it('shell.run returns a final envelope through call and streaming APIs', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'air-shell-')) const registry = createRegistry(projectRoot) const ctx = { session_id: 's', project_id: 'p', project_root: projectRoot, agent_id: 'a', agent_type: 'executor' as const } const final = await registry.call({ call_id: 'shell-call', name: 'shell.run', arguments: { command: 'printf ok' } }, ctx) expect(final.status).toBe('ok') expect((final.output as any).exit_code).toBe(0) expect((final.output as any).stdout).toBe('ok') expect((final.metadata as any).is_final).toBe(true) const chunks = [] for await (const chunk of registry.call_streaming({ call_id: 'shell-stream', name: 'shell.run', arguments: { command: 'printf ok' } }, ctx)) { chunks.push(chunk) } expect(chunks.length).toBeGreaterThanOrEqual(2) expect((chunks.at(-1)!.metadata as any).is_final).toBe(true) }) it('scheduler does not mark running tasks completed without a worker result', async () => { const workerManager = { has_running: () => false, get_handle_for_task: () => undefined, get_result_for_task: () => undefined, } const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor()) await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) scheduler.get_graph().update_status('task-1' as any, 'running') await scheduler.step() expect(scheduler.get_graph().get_tasks_by_status('running').length).toBe(1) expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0) }) it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => { const workerManager = { has_running: () => false, get_handle_for_task: () => ({ worker_id: 'agent-task-1' }), get_result_for_task: () => ({ task_id: 'task-1', agent_id: 'agent-task-1', agent_type: 'executor', status: 'blocked', summary: 'blocked by worker', changed_files: [], artifacts: [], verification: [], risks: [], follow_up_tasks: [], evidence_refs: [], result: {}, }), } const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor()) await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }]) scheduler.get_graph().update_status('task-1' as any, 'running') const finalState = await scheduler.run_until_idle() expect(finalState).toBe('BLOCKED') expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1) }) it('MainAgent answer mode uses assembled project context', async () => { const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-')) writeFileSync(join(projectRoot, 'visible.txt'), 'visible') const assembler = new ContextAssembler() const provider = { async complete_text(messages: Array<{ role: string; content: string }>) { const joined = messages.map(m => m.content).join('\n') return { content: joined.includes('visible.txt') || joined.includes('Project') ? 'context seen' : 'missing context' } } } const agent = new MainAgent({ session_id: 's' as any, project_id: 'p' as any, provider_manager: provider, context_assembler: assembler, project_root: projectRoot, agent_id: 'main-agent' as any, }) const result = await agent.handle_user_message('what files are in this project?') expect(result.action).toBe('answer') expect(result.response).toBe('context seen') }) it('destructive requests enter confirmation and rejection returns to idle', async () => { const agent = new MainAgent({ session_id: 's' as any, project_id: 'p' as any }) const result = await agent.handle_user_message('delete hello.txt') expect(result.action).toBe('delegate') expect(agent.state).toBe('CONFIRMING') await agent.handle_confirmation(false) expect(agent.state).toBe('IDLE') }) })