| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407 |
- import { describe, expect, mock, test } from 'bun:test';
- import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
- import { BackgroundTaskManager } from './background-manager';
- // Mock the plugin context
- function createMockContext(overrides?: {
- sessionCreateResult?: { data?: { id?: string } };
- sessionStatusResult?: { data?: Record<string, { type: string }> };
- sessionMessagesResult?: {
- data?: Array<{
- info?: { role: string };
- parts?: Array<{ type: string; text?: string }>;
- }>;
- };
- promptImpl?: (args: any) => Promise<unknown>;
- }) {
- let callCount = 0;
- return {
- client: {
- session: {
- create: mock(async () => {
- callCount++;
- return (
- overrides?.sessionCreateResult ?? {
- data: { id: `test-session-${callCount}` },
- }
- );
- }),
- status: mock(
- async () => overrides?.sessionStatusResult ?? { data: {} },
- ),
- messages: mock(
- async () => overrides?.sessionMessagesResult ?? { data: [] },
- ),
- prompt: mock(async (args: any) => {
- if (overrides?.promptImpl) {
- return await overrides.promptImpl(args);
- }
- return {};
- }),
- abort: mock(async () => ({})),
- },
- },
- directory: '/test/directory',
- } as any;
- }
- describe('BackgroundTaskManager', () => {
- describe('constructor', () => {
- test('creates manager with defaults', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- expect(manager).toBeDefined();
- });
- test('creates manager with tmux config', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx, {
- enabled: true,
- layout: 'main-vertical',
- main_pane_size: 60,
- });
- expect(manager).toBeDefined();
- });
- test('creates manager with background config', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx, undefined, {
- background: {
- maxConcurrentStarts: 5,
- },
- });
- expect(manager).toBeDefined();
- });
- });
- describe('launch (fire-and-forget)', () => {
- test('returns task immediately with pending or starting status', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'Find all test files',
- description: 'Test file search',
- parentSessionId: 'parent-123',
- });
- expect(task.id).toMatch(/^bg_/);
- // Task may be pending (in queue) or starting (already started)
- expect(['pending', 'starting']).toContain(task.status);
- expect(task.sessionId).toBeUndefined();
- expect(task.agent).toBe('explorer');
- expect(task.description).toBe('Test file search');
- expect(task.startedAt).toBeDefined();
- });
- test('sessionId is set asynchronously when task starts', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Immediately after launch, no sessionId
- expect(task.sessionId).toBeUndefined();
- // Wait for microtask queue to process
- await Promise.resolve();
- await Promise.resolve();
- // After background start, sessionId should be set
- expect(task.sessionId).toBeDefined();
- expect(task.status).toBe('running');
- });
- test('task fails when session creation fails', async () => {
- const ctx = createMockContext({ sessionCreateResult: { data: {} } });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- await Promise.resolve();
- await Promise.resolve();
- expect(task.status).toBe('failed');
- expect(task.error).toBe('Failed to create background session');
- });
- test('multiple launches return immediately', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task1 = manager.launch({
- agent: 'explorer',
- prompt: 'test1',
- description: 'test1',
- parentSessionId: 'parent-123',
- });
- const task2 = manager.launch({
- agent: 'oracle',
- prompt: 'test2',
- description: 'test2',
- parentSessionId: 'parent-123',
- });
- const task3 = manager.launch({
- agent: 'fixer',
- prompt: 'test3',
- description: 'test3',
- parentSessionId: 'parent-123',
- });
- // All return immediately with pending or starting status
- expect(['pending', 'starting']).toContain(task1.status);
- expect(['pending', 'starting']).toContain(task2.status);
- expect(['pending', 'starting']).toContain(task3.status);
- });
- });
- describe('handleSessionStatus', () => {
- test('completes task when session becomes idle', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'Result text' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- // Simulate session.idle event
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- expect(task.status).toBe('completed');
- expect(task.result).toBe('Result text');
- });
- test('ignores non-idle status', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Simulate session.busy event
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'busy' },
- },
- });
- expect(task.status).toBe('running');
- });
- test('ignores non-matching session ID', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Simulate event for different session
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: 'other-session-id',
- status: { type: 'idle' },
- },
- });
- expect(task.status).toBe('running');
- });
- });
- describe('getResult', () => {
- test('returns null for unknown task', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const result = manager.getResult('unknown-task-id');
- expect(result).toBeNull();
- });
- test('returns task immediately (no blocking)', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- const result = manager.getResult(task.id);
- expect(result).toBeDefined();
- expect(result?.id).toBe(task.id);
- });
- });
- describe('waitForCompletion', () => {
- test('waits for task to complete', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'Done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- // Trigger completion via session.status event
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- // Now waitForCompletion should return immediately
- const result = await manager.waitForCompletion(task.id, 5000);
- expect(result?.status).toBe('completed');
- expect(result?.result).toBe('Done');
- });
- test('returns immediately if already completed', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'Done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- // Trigger completion
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- // Now wait should return immediately
- const result = await manager.waitForCompletion(task.id, 5000);
- expect(result?.status).toBe('completed');
- });
- test('returns null for unknown task', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const result = await manager.waitForCompletion('unknown-task-id', 5000);
- expect(result).toBeNull();
- });
- });
- describe('cancel', () => {
- test('cancels pending task before it starts', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- const count = manager.cancel(task.id);
- expect(count).toBe(1);
- const result = manager.getResult(task.id);
- expect(result?.status).toBe('cancelled');
- });
- test('cancels running task', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- const count = manager.cancel(task.id);
- expect(count).toBe(1);
- const result = manager.getResult(task.id);
- expect(result?.status).toBe('cancelled');
- });
- test('returns 0 when cancelling unknown task', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const count = manager.cancel('unknown-task-id');
- expect(count).toBe(0);
- });
- test('cancels all pending/running tasks when no ID provided', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- manager.launch({
- agent: 'explorer',
- prompt: 'test1',
- description: 'test1',
- parentSessionId: 'parent-123',
- });
- manager.launch({
- agent: 'oracle',
- prompt: 'test2',
- description: 'test2',
- parentSessionId: 'parent-123',
- });
- const count = manager.cancel();
- expect(count).toBe(2);
- });
- test('does not cancel already completed tasks', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'Done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- // Trigger completion
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- // Now try to cancel - should fail since already completed
- const count = manager.cancel(task.id);
- expect(count).toBe(0);
- });
- });
- describe('BackgroundTask logic', () => {
- test('falls back to next model when first model prompt fails', async () => {
- let promptCalls = 0;
- const ctx = createMockContext({
- promptImpl: async (args) => {
- const isTaskPrompt =
- typeof args.path?.id === 'string' &&
- args.path.id.startsWith('test-session-');
- const isParentNotification = !isTaskPrompt;
- if (isParentNotification) return {};
- promptCalls += 1;
- const modelRef = args.body?.model;
- if (
- modelRef?.providerID === 'openai' &&
- modelRef?.modelID === 'gpt-5.4'
- ) {
- throw new Error('primary failed');
- }
- return {};
- },
- });
- const manager = new BackgroundTaskManager(ctx, undefined, {
- fallback: {
- enabled: true,
- timeoutMs: 15000,
- retryDelayMs: 0,
- chains: {
- explorer: ['openai/gpt-5.4', 'opencode/gpt-5-nano'],
- },
- },
- });
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Yield to let the fire-and-forget async chain complete
- // (retryDelayMs: 0 eliminates the inter-attempt delay)
- await new Promise((r) => setTimeout(r, 10));
- expect(task.status).toBe('running');
- expect(promptCalls).toBe(2);
- // Verify session.abort was called between attempts
- expect(ctx.client.session.abort).toHaveBeenCalled();
- });
- test('fails task when all fallback models fail', async () => {
- const ctx = createMockContext({
- promptImpl: async (args) => {
- const isTaskPrompt =
- typeof args.path?.id === 'string' &&
- args.path.id.startsWith('test-session-');
- const isParentNotification = !isTaskPrompt;
- if (isParentNotification) return {};
- throw new Error('all models failing');
- },
- });
- const manager = new BackgroundTaskManager(ctx, undefined, {
- fallback: {
- enabled: true,
- timeoutMs: 15000,
- retryDelayMs: 0,
- chains: {
- explorer: ['openai/gpt-5.4', 'opencode/gpt-5-nano'],
- },
- },
- });
- const task = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'parent-123',
- });
- // Yield to let the fire-and-forget async chain complete
- // (retryDelayMs: 0 eliminates the inter-attempt delay)
- await new Promise((r) => setTimeout(r, 10));
- expect(task.status).toBe('failed');
- expect(task.error).toContain('All fallback models failed');
- // Verify session.abort was called: once between attempts + once in completeTask
- expect(ctx.client.session.abort).toHaveBeenCalledTimes(2);
- });
- test('extracts content from multiple types and messages', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [
- { type: 'reasoning', text: 'I am thinking...' },
- { type: 'text', text: 'First part.' },
- ],
- },
- {
- info: { role: 'assistant' },
- parts: [
- { type: 'text', text: 'Second part.' },
- { type: 'text', text: '' }, // Should be ignored
- ],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- const task = manager.launch({
- agent: 'test',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'p1',
- });
- // Wait for task to start
- await Promise.resolve();
- await Promise.resolve();
- // Trigger completion
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- expect(task.status).toBe('completed');
- expect(task.result).toContain('I am thinking...');
- expect(task.result).toContain('First part.');
- expect(task.result).toContain('Second part.');
- // Check for double newline join
- expect(task.result).toBe(
- 'I am thinking...\n\nFirst part.\n\nSecond part.',
- );
- });
- test('task has completedAt timestamp on completion or cancellation', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- // Test completion timestamp
- const task1 = manager.launch({
- agent: 'test',
- prompt: 't1',
- description: 'd1',
- parentSessionId: 'p1',
- });
- await Promise.resolve();
- await Promise.resolve();
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task1.sessionId,
- status: { type: 'idle' },
- },
- });
- expect(task1.completedAt).toBeInstanceOf(Date);
- expect(task1.status).toBe('completed');
- // Test cancellation timestamp
- const task2 = manager.launch({
- agent: 'test',
- prompt: 't2',
- description: 'd2',
- parentSessionId: 'p2',
- });
- manager.cancel(task2.id);
- expect(task2.completedAt).toBeInstanceOf(Date);
- expect(task2.status).toBe('cancelled');
- });
- test('always sends notification to parent session on completion', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx, undefined, {
- background: { maxConcurrentStarts: 10 },
- });
- const task = manager.launch({
- agent: 'test',
- prompt: 't',
- description: 'd',
- parentSessionId: 'parent-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: task.sessionId,
- status: { type: 'idle' },
- },
- });
- // Should have called prompt.append for notification
- expect(ctx.client.session.prompt).toHaveBeenCalled();
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body?: { parts?: Array<{ text?: string }> } }]
- >;
- const notificationCall = promptCalls[promptCalls.length - 1];
- expect(
- notificationCall[0].body?.parts?.[0]?.text?.includes(
- SLIM_INTERNAL_INITIATOR_MARKER,
- ),
- ).toBe(true);
- });
- });
- describe('subagent delegation restrictions', () => {
- test('spawned explorer gets tools disabled (leaf node)', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // First, simulate orchestrator starting (parent session with no parent)
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Verify orchestrator's session is tracked
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- // Launch explorer from orchestrator - explorer is a leaf node so tools disabled
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: orchestratorSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- // Explorer cannot delegate, so delegation tools are hidden
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('spawned designer gets tools disabled (leaf node)', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // First, launch an orchestrator task
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Launch designer from orchestrator - designer is a leaf node, so tools are disabled
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- manager.launch({
- agent: 'designer',
- prompt: 'test',
- description: 'test',
- parentSessionId: orchestratorSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- // Designer is a leaf node, so delegation tools are hidden
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('spawned explorer from designer gets tools disabled (leaf node)', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch a designer task
- const designerTask = manager.launch({
- agent: 'designer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Launch explorer from designer - explorer is a leaf node so tools disabled
- const designerSessionId = designerTask.sessionId;
- if (!designerSessionId)
- throw new Error('Expected sessionId to be defined');
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: designerSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('librarian cannot delegate to any subagents', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch a librarian task
- const librarianTask = manager.launch({
- agent: 'librarian',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Launch subagent from librarian - should have tools disabled
- const librarianSessionId = librarianTask.sessionId;
- if (!librarianSessionId)
- throw new Error('Expected sessionId to be defined');
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: librarianSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('oracle cannot delegate to any subagents', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch an oracle task
- const oracleTask = manager.launch({
- agent: 'oracle',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- // Launch subagent from oracle - should have tools disabled
- const oracleSessionId = oracleTask.sessionId;
- if (!oracleSessionId) throw new Error('Expected sessionId to be defined');
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: oracleSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('spawned explorer from unknown parent gets tools disabled (leaf node)', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch explorer from unknown parent session (root orchestrator)
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'unknown-session-id',
- });
- await Promise.resolve();
- await Promise.resolve();
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- // Explorer is a leaf agent — tools disabled regardless of parent
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('isAgentAllowed returns true for valid delegations', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- // Orchestrator can delegate to all subagents
- expect(manager.isAgentAllowed(orchestratorSessionId, 'explorer')).toBe(
- true,
- );
- expect(manager.isAgentAllowed(orchestratorSessionId, 'fixer')).toBe(true);
- expect(manager.isAgentAllowed(orchestratorSessionId, 'designer')).toBe(
- true,
- );
- expect(manager.isAgentAllowed(orchestratorSessionId, 'librarian')).toBe(
- true,
- );
- expect(manager.isAgentAllowed(orchestratorSessionId, 'oracle')).toBe(
- true,
- );
- });
- test('isAgentAllowed returns false for invalid delegations', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- const fixerTask = manager.launch({
- agent: 'fixer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const fixerSessionId = fixerTask.sessionId;
- if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
- // Fixer cannot delegate to any subagents
- expect(manager.isAgentAllowed(fixerSessionId, 'explorer')).toBe(false);
- expect(manager.isAgentAllowed(fixerSessionId, 'oracle')).toBe(false);
- expect(manager.isAgentAllowed(fixerSessionId, 'designer')).toBe(false);
- });
- test('isAgentAllowed returns false for leaf agents', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Explorer is a leaf agent
- const explorerTask = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const explorerSessionId = explorerTask.sessionId;
- if (!explorerSessionId)
- throw new Error('Expected sessionId to be defined');
- expect(manager.isAgentAllowed(explorerSessionId, 'fixer')).toBe(false);
- // Librarian is also a leaf agent
- const librarianTask = manager.launch({
- agent: 'librarian',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const librarianSessionId = librarianTask.sessionId;
- if (!librarianSessionId)
- throw new Error('Expected sessionId to be defined');
- expect(manager.isAgentAllowed(librarianSessionId, 'explorer')).toBe(
- false,
- );
- });
- test('isAgentAllowed treats unknown session as root orchestrator', () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Unknown sessions default to orchestrator, which can delegate to all subagents
- expect(manager.isAgentAllowed('unknown-session', 'explorer')).toBe(true);
- expect(manager.isAgentAllowed('unknown-session', 'fixer')).toBe(true);
- expect(manager.isAgentAllowed('unknown-session', 'designer')).toBe(true);
- expect(manager.isAgentAllowed('unknown-session', 'librarian')).toBe(true);
- expect(manager.isAgentAllowed('unknown-session', 'oracle')).toBe(true);
- });
- test('unknown agent type defaults to explorer-only delegation', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch a task with an agent type not in SUBAGENT_DELEGATION_RULES
- const customTask = manager.launch({
- agent: 'custom-agent',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const customSessionId = customTask.sessionId;
- if (!customSessionId) throw new Error('Expected sessionId to be defined');
- // Unknown agent types should default to explorer-only
- expect(manager.getAllowedSubagents(customSessionId)).toEqual([
- 'explorer',
- ]);
- expect(manager.isAgentAllowed(customSessionId, 'explorer')).toBe(true);
- expect(manager.isAgentAllowed(customSessionId, 'fixer')).toBe(false);
- expect(manager.isAgentAllowed(customSessionId, 'oracle')).toBe(false);
- });
- test('spawned explorer from custom agent gets tools disabled (leaf node)', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Launch a custom agent first to get a tracked session
- const parentTask = manager.launch({
- agent: 'custom-agent',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const parentSessionId = parentTask.sessionId;
- if (!parentSessionId) throw new Error('Expected sessionId to be defined');
- // Launch explorer from custom agent - explorer is leaf, tools disabled
- manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: parentSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- // Explorer is a leaf agent — tools disabled regardless of parent
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const lastCall = promptCalls[promptCalls.length - 1];
- expect(lastCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- });
- test('full chain: orchestrator → designer → explorer', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Level 1: Launch orchestrator
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'coordinate work',
- description: 'orchestrator',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- // Level 2: Launch designer from orchestrator
- const designerTask = manager.launch({
- agent: 'designer',
- prompt: 'design UI',
- description: 'designer',
- parentSessionId: orchestratorSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const designerSessionId = designerTask.sessionId;
- if (!designerSessionId)
- throw new Error('Expected sessionId to be defined');
- // Designer is a leaf node, so delegation tools stay disabled
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const designerPromptCall = promptCalls[1];
- expect(designerPromptCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- // Designer is a leaf node and cannot spawn subagents
- expect(manager.isAgentAllowed(designerSessionId, 'explorer')).toBe(false);
- expect(manager.isAgentAllowed(designerSessionId, 'fixer')).toBe(false);
- expect(manager.isAgentAllowed(designerSessionId, 'oracle')).toBe(false);
- // Level 3: Launch explorer from designer
- const explorerTask = manager.launch({
- agent: 'explorer',
- prompt: 'find patterns',
- description: 'explorer',
- parentSessionId: designerSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const explorerSessionId = explorerTask.sessionId;
- if (!explorerSessionId)
- throw new Error('Expected sessionId to be defined');
- // Explorer gets tools DISABLED
- const explorerPromptCall = promptCalls[2];
- expect(explorerPromptCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- // Explorer is a dead end
- expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
- });
- test('chain enforcement: fixer cannot spawn unauthorized agents mid-chain', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Orchestrator spawns fixer
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- const fixerTask = manager.launch({
- agent: 'fixer',
- prompt: 'test',
- description: 'test',
- parentSessionId: orchestratorSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const fixerSessionId = fixerTask.sessionId;
- if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
- // Fixer should be blocked from spawning these agents
- expect(manager.isAgentAllowed(fixerSessionId, 'oracle')).toBe(false);
- expect(manager.isAgentAllowed(fixerSessionId, 'designer')).toBe(false);
- expect(manager.isAgentAllowed(fixerSessionId, 'librarian')).toBe(false);
- expect(manager.isAgentAllowed(fixerSessionId, 'fixer')).toBe(false);
- // Explorer is also blocked (fixer is a leaf node)
- expect(manager.isAgentAllowed(fixerSessionId, 'explorer')).toBe(false);
- expect(manager.getAllowedSubagents(fixerSessionId)).toEqual([]);
- });
- test('chain: completed parent does not affect child permissions', async () => {
- const ctx = createMockContext({
- sessionMessagesResult: {
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'done' }],
- },
- ],
- },
- });
- const manager = new BackgroundTaskManager(ctx);
- // Launch designer
- const designerTask = manager.launch({
- agent: 'designer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const designerSessionId = designerTask.sessionId;
- if (!designerSessionId)
- throw new Error('Expected sessionId to be defined');
- // Launch explorer from designer BEFORE designer completes
- const explorerTask = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: designerSessionId,
- });
- await Promise.resolve();
- await Promise.resolve();
- const explorerSessionId = explorerTask.sessionId;
- if (!explorerSessionId)
- throw new Error('Expected sessionId to be defined');
- // Explorer has its own tracking — tools disabled
- const promptCalls = ctx.client.session.prompt.mock.calls as Array<
- [{ body: { tools?: Record<string, boolean> } }]
- >;
- const explorerPromptCall = promptCalls[1];
- expect(explorerPromptCall[0].body.tools).toEqual({
- background_task: false,
- task: false,
- });
- // Now complete the designer (cleans up designer's agentBySessionId entry)
- await manager.handleSessionStatus({
- type: 'session.status',
- properties: {
- sessionID: designerSessionId,
- status: { type: 'idle' },
- },
- });
- expect(designerTask.status).toBe('completed');
- // Explorer's own session tracking is independent — still works
- expect(manager.isAgentAllowed(explorerSessionId, 'fixer')).toBe(false);
- expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
- });
- test('getAllowedSubagents returns correct lists', async () => {
- const ctx = createMockContext();
- const manager = new BackgroundTaskManager(ctx);
- // Orchestrator -> all 5 subagent names
- const orchestratorTask = manager.launch({
- agent: 'orchestrator',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const orchestratorSessionId = orchestratorTask.sessionId;
- if (!orchestratorSessionId)
- throw new Error('Expected sessionId to be defined');
- expect(manager.getAllowedSubagents(orchestratorSessionId)).toEqual([
- 'explorer',
- 'librarian',
- 'oracle',
- 'designer',
- 'fixer',
- 'council',
- ]);
- // Fixer -> empty (leaf node)
- const fixerTask = manager.launch({
- agent: 'fixer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const fixerSessionId = fixerTask.sessionId;
- if (!fixerSessionId) throw new Error('Expected sessionId to be defined');
- expect(manager.getAllowedSubagents(fixerSessionId)).toEqual([]);
- // Designer -> only explorer
- const designerTask = manager.launch({
- agent: 'designer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const designerSessionId = designerTask.sessionId;
- if (!designerSessionId)
- throw new Error('Expected sessionId to be defined');
- expect(manager.getAllowedSubagents(designerSessionId)).toEqual([]);
- // Explorer -> empty (leaf)
- const explorerTask = manager.launch({
- agent: 'explorer',
- prompt: 'test',
- description: 'test',
- parentSessionId: 'root-session',
- });
- await Promise.resolve();
- await Promise.resolve();
- const explorerSessionId = explorerTask.sessionId;
- if (!explorerSessionId)
- throw new Error('Expected sessionId to be defined');
- expect(manager.getAllowedSubagents(explorerSessionId)).toEqual([]);
- // Unknown session -> orchestrator (all subagents)
- expect(manager.getAllowedSubagents('unknown-session')).toEqual([
- 'explorer',
- 'librarian',
- 'oracle',
- 'designer',
- 'fixer',
- 'council',
- ]);
- });
- });
- });
|