| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- import { describe, expect, mock, test } from 'bun:test';
- import * as fs from 'node:fs';
- import * as os from 'node:os';
- import * as path from 'node:path';
- import { SubagentDepthTracker } from '../../utils/subagent-depth';
- import { createSubtaskState } from './state';
- import { createReadSessionTool, createSubtaskTool } from './tools';
- function makeTempDir() {
- return fs.mkdtempSync(path.join(os.tmpdir(), 'omos-subtask-tool-'));
- }
- describe('subtask tool', () => {
- test('runs a worker child session and returns its subtask summary', async () => {
- const directory = makeTempDir();
- try {
- fs.mkdirSync(path.join(directory, 'src'));
- fs.writeFileSync(path.join(directory, 'src/index.ts'), 'export {}\n');
- const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
- const sessionPrompt = mock(async () => ({}));
- const sessionMessages = mock(async () => ({
- data: [
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: '<subtask_summary>\nSummary from worker\n</subtask_summary>',
- },
- ],
- },
- ],
- }));
- const sessionAbort = mock(async () => ({}));
- const state = createSubtaskState();
- const tool = createSubtaskTool(
- {
- directory,
- client: {
- session: {
- abort: sessionAbort,
- create: sessionCreate,
- messages: sessionMessages,
- prompt: sessionPrompt,
- },
- },
- } as any,
- state,
- new SubagentDepthTracker(),
- );
- const result = await tool.execute(
- { prompt: 'Continue implementation', files: ['src/index.ts'] },
- { sessionID: 'ses_old' } as any,
- );
- expect(result).toContain('task_id: ses_new');
- expect(result).toContain('<subtask_summary>');
- expect(result).toContain('Summary from worker');
- expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
- expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
- expect(sessionCreate).toHaveBeenCalledWith({
- responseStyle: 'data',
- throwOnError: true,
- query: { directory },
- body: {
- parentID: 'ses_old',
- title: 'Subtask worker from ses_old',
- },
- });
- expect(sessionPrompt).toHaveBeenCalledTimes(1);
- const promptCall = sessionPrompt.mock.calls[0]?.[0] as {
- path: { id: string };
- body: {
- agent: string;
- parts: Array<Record<string, unknown>>;
- tools?: Record<string, boolean>;
- };
- };
- expect(promptCall.path.id).toBe('ses_new');
- expect(promptCall.body.agent).toBe('orchestrator');
- expect(promptCall.body.tools).toBeUndefined();
- const workerPrompt = String(promptCall.body.parts[0]?.text);
- expect(promptCall.body.parts[0]).toMatchObject({
- type: 'text',
- text: expect.stringContaining(
- 'You are a subtask worker spawned by parent session ses_old',
- ),
- });
- expect(workerPrompt).toContain('Your job is bounded');
- expect(workerPrompt).toContain('TASK:');
- expect(workerPrompt).toContain('FILES PROVIDED:');
- expect(workerPrompt).toContain('<subtask_summary>');
- expect(promptCall.body.parts).toContainEqual(
- expect.objectContaining({ synthetic: true, type: 'text' }),
- );
- expect(sessionMessages).toHaveBeenCalledWith({
- path: { id: 'ses_new' },
- query: { directory },
- });
- expect(sessionAbort).toHaveBeenCalledWith({
- path: { id: 'ses_new' },
- query: { directory },
- });
- } finally {
- fs.rmSync(directory, { recursive: true, force: true });
- }
- });
- test('normalizes nested worker summary tags', async () => {
- const directory = makeTempDir();
- try {
- const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
- const sessionPrompt = mock(async () => ({}));
- const sessionMessages = mock(async () => ({
- data: [
- {
- info: { role: 'assistant' },
- parts: [
- {
- type: 'text',
- text: '<subtask_summary><subtask_summary>Inner</subtask_summary></subtask_summary>',
- },
- ],
- },
- ],
- }));
- const sessionAbort = mock(async () => ({}));
- const state = createSubtaskState();
- const tool = createSubtaskTool(
- {
- directory,
- client: {
- session: {
- abort: sessionAbort,
- create: sessionCreate,
- messages: sessionMessages,
- prompt: sessionPrompt,
- },
- },
- } as any,
- state,
- );
- const result = await tool.execute({ prompt: 'Summarize only' }, {
- sessionID: 'ses_old',
- } as any);
- expect(result).toContain('Inner');
- expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
- expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
- } finally {
- fs.rmSync(directory, { recursive: true, force: true });
- }
- });
- test('aborts child session when parent tool call is cancelled', async () => {
- const directory = makeTempDir();
- const controller = new AbortController();
- try {
- const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
- const sessionPrompt = mock(() => {
- setTimeout(() => controller.abort(), 0);
- return new Promise(() => {});
- });
- const sessionMessages = mock(async () => ({ data: [] }));
- const sessionAbort = mock(async () => ({}));
- const state = createSubtaskState();
- const tool = createSubtaskTool(
- {
- directory,
- client: {
- session: {
- abort: sessionAbort,
- create: sessionCreate,
- messages: sessionMessages,
- prompt: sessionPrompt,
- },
- },
- } as any,
- state,
- );
- await expect(
- tool.execute({ prompt: 'Cancel me' }, {
- sessionID: 'ses_old',
- abort: controller.signal,
- } as any),
- ).rejects.toThrow('Prompt cancelled');
- expect(sessionAbort).toHaveBeenCalledWith({
- path: { id: 'ses_new' },
- query: { directory },
- });
- expect(state.isSubtaskSession('ses_new')).toBe(false);
- expect(sessionMessages).not.toHaveBeenCalled();
- } finally {
- fs.rmSync(directory, { recursive: true, force: true });
- }
- });
- test('blocks nested subtask calls from a subtask worker', async () => {
- const directory = makeTempDir();
- try {
- let nestedResult = '';
- const state = createSubtaskState();
- const tool = createSubtaskTool(
- {
- directory,
- client: {
- session: {
- abort: mock(async () => ({})),
- create: mock(async () => ({ data: { id: 'ses_subtask' } })),
- messages: mock(async () => ({
- data: [
- {
- info: { role: 'assistant' },
- parts: [{ type: 'text', text: 'done' }],
- },
- ],
- })),
- prompt: mock(async () => {
- nestedResult = String(
- await tool.execute({ prompt: 'nested subtask' }, {
- sessionID: 'ses_subtask',
- } as any),
- );
- }),
- },
- },
- } as any,
- state,
- new SubagentDepthTracker(),
- );
- await tool.execute({ prompt: 'outer subtask' }, {
- sessionID: 'ses_old',
- } as any);
- expect(nestedResult).toContain('Nested subtask is disabled');
- } finally {
- fs.rmSync(directory, { recursive: true, force: true });
- }
- });
- test('honors custom timeoutMs option', async () => {
- const directory = makeTempDir();
- try {
- const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
- const sessionPrompt = mock(() => new Promise(() => {}));
- const sessionMessages = mock(async () => ({ data: [] }));
- const sessionAbort = mock(async () => ({}));
- const state = createSubtaskState();
- const tool = createSubtaskTool(
- {
- directory,
- client: {
- session: {
- abort: sessionAbort,
- create: sessionCreate,
- messages: sessionMessages,
- prompt: sessionPrompt,
- },
- },
- } as any,
- state,
- undefined,
- { timeoutMs: 5 },
- );
- await expect(
- tool.execute({ prompt: 'Will time out' }, {
- sessionID: 'ses_old',
- } as any),
- ).rejects.toThrow('Prompt timed out after 5ms');
- expect(sessionAbort).toHaveBeenCalledWith({
- path: { id: 'ses_new' },
- query: { directory },
- });
- } finally {
- fs.rmSync(directory, { recursive: true, force: true });
- }
- });
- });
- describe('read_session tool', () => {
- test('formats session transcripts', async () => {
- const messages = mock(async () => ({
- data: [
- { info: { role: 'user' }, parts: [{ type: 'text', text: 'Hi' }] },
- {
- info: { role: 'assistant' },
- parts: [
- { type: 'text', text: 'Hello' },
- {
- type: 'tool',
- tool: 'read',
- state: { status: 'completed', title: 'Read file' },
- },
- ],
- },
- ],
- }));
- const state = createSubtaskState();
- state.markSession('ses_worker', 'ses_old');
- const result = await createReadSessionTool(
- { session: { messages } } as any,
- state,
- ).execute({ sessionID: 'ses_old' }, { sessionID: 'ses_worker' } as any);
- expect(result).toContain('## User');
- expect(result).toContain('Hi');
- expect(result).toContain('## Assistant');
- expect(result).toContain('[Tool: read] Read file');
- });
- test('blocks reads outside the source session', async () => {
- const state = createSubtaskState();
- state.markSession('ses_worker', 'ses_old');
- const messages = mock(async () => ({ data: [] }));
- const result = await createReadSessionTool(
- { session: { messages } } as any,
- state,
- ).execute({ sessionID: 'ses_other' }, { sessionID: 'ses_worker' } as any);
- expect(result).toContain('can only read the source session');
- expect(messages).not.toHaveBeenCalled();
- });
- });
|