tools.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import { describe, expect, mock, test } from 'bun:test';
  2. import * as fs from 'node:fs';
  3. import * as os from 'node:os';
  4. import * as path from 'node:path';
  5. import { SubagentDepthTracker } from '../../utils/subagent-depth';
  6. import { createSubtaskState } from './state';
  7. import { createReadSessionTool, createSubtaskTool } from './tools';
  8. function makeTempDir() {
  9. return fs.mkdtempSync(path.join(os.tmpdir(), 'omos-subtask-tool-'));
  10. }
  11. describe('subtask tool', () => {
  12. test('runs a worker child session and returns its subtask summary', async () => {
  13. const directory = makeTempDir();
  14. try {
  15. fs.mkdirSync(path.join(directory, 'src'));
  16. fs.writeFileSync(path.join(directory, 'src/index.ts'), 'export {}\n');
  17. const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
  18. const sessionPrompt = mock(async () => ({}));
  19. const sessionMessages = mock(async () => ({
  20. data: [
  21. {
  22. info: { role: 'assistant' },
  23. parts: [
  24. {
  25. type: 'text',
  26. text: '<subtask_summary>\nSummary from worker\n</subtask_summary>',
  27. },
  28. ],
  29. },
  30. ],
  31. }));
  32. const sessionAbort = mock(async () => ({}));
  33. const state = createSubtaskState();
  34. const tool = createSubtaskTool(
  35. {
  36. directory,
  37. client: {
  38. session: {
  39. abort: sessionAbort,
  40. create: sessionCreate,
  41. messages: sessionMessages,
  42. prompt: sessionPrompt,
  43. },
  44. },
  45. } as any,
  46. state,
  47. new SubagentDepthTracker(),
  48. );
  49. const result = await tool.execute(
  50. { prompt: 'Continue implementation', files: ['src/index.ts'] },
  51. { sessionID: 'ses_old' } as any,
  52. );
  53. expect(result).toContain('task_id: ses_new');
  54. expect(result).toContain('<subtask_summary>');
  55. expect(result).toContain('Summary from worker');
  56. expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
  57. expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
  58. expect(sessionCreate).toHaveBeenCalledWith({
  59. responseStyle: 'data',
  60. throwOnError: true,
  61. query: { directory },
  62. body: {
  63. parentID: 'ses_old',
  64. title: 'Subtask worker from ses_old',
  65. },
  66. });
  67. expect(sessionPrompt).toHaveBeenCalledTimes(1);
  68. const promptCall = sessionPrompt.mock.calls[0]?.[0] as {
  69. path: { id: string };
  70. body: {
  71. agent: string;
  72. parts: Array<Record<string, unknown>>;
  73. tools?: Record<string, boolean>;
  74. };
  75. };
  76. expect(promptCall.path.id).toBe('ses_new');
  77. expect(promptCall.body.agent).toBe('orchestrator');
  78. expect(promptCall.body.tools).toBeUndefined();
  79. const workerPrompt = String(promptCall.body.parts[0]?.text);
  80. expect(promptCall.body.parts[0]).toMatchObject({
  81. type: 'text',
  82. text: expect.stringContaining(
  83. 'You are a subtask worker spawned by parent session ses_old',
  84. ),
  85. });
  86. expect(workerPrompt).toContain('Your job is bounded');
  87. expect(workerPrompt).toContain('TASK:');
  88. expect(workerPrompt).toContain('FILES PROVIDED:');
  89. expect(workerPrompt).toContain('<subtask_summary>');
  90. expect(promptCall.body.parts).toContainEqual(
  91. expect.objectContaining({ synthetic: true, type: 'text' }),
  92. );
  93. expect(sessionMessages).toHaveBeenCalledWith({
  94. path: { id: 'ses_new' },
  95. query: { directory },
  96. });
  97. expect(sessionAbort).toHaveBeenCalledWith({
  98. path: { id: 'ses_new' },
  99. query: { directory },
  100. });
  101. } finally {
  102. fs.rmSync(directory, { recursive: true, force: true });
  103. }
  104. });
  105. test('normalizes nested worker summary tags', async () => {
  106. const directory = makeTempDir();
  107. try {
  108. const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
  109. const sessionPrompt = mock(async () => ({}));
  110. const sessionMessages = mock(async () => ({
  111. data: [
  112. {
  113. info: { role: 'assistant' },
  114. parts: [
  115. {
  116. type: 'text',
  117. text: '<subtask_summary><subtask_summary>Inner</subtask_summary></subtask_summary>',
  118. },
  119. ],
  120. },
  121. ],
  122. }));
  123. const sessionAbort = mock(async () => ({}));
  124. const state = createSubtaskState();
  125. const tool = createSubtaskTool(
  126. {
  127. directory,
  128. client: {
  129. session: {
  130. abort: sessionAbort,
  131. create: sessionCreate,
  132. messages: sessionMessages,
  133. prompt: sessionPrompt,
  134. },
  135. },
  136. } as any,
  137. state,
  138. );
  139. const result = await tool.execute({ prompt: 'Summarize only' }, {
  140. sessionID: 'ses_old',
  141. } as any);
  142. expect(result).toContain('Inner');
  143. expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
  144. expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
  145. } finally {
  146. fs.rmSync(directory, { recursive: true, force: true });
  147. }
  148. });
  149. test('aborts child session when parent tool call is cancelled', async () => {
  150. const directory = makeTempDir();
  151. const controller = new AbortController();
  152. try {
  153. const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
  154. const sessionPrompt = mock(() => {
  155. setTimeout(() => controller.abort(), 0);
  156. return new Promise(() => {});
  157. });
  158. const sessionMessages = mock(async () => ({ data: [] }));
  159. const sessionAbort = mock(async () => ({}));
  160. const state = createSubtaskState();
  161. const tool = createSubtaskTool(
  162. {
  163. directory,
  164. client: {
  165. session: {
  166. abort: sessionAbort,
  167. create: sessionCreate,
  168. messages: sessionMessages,
  169. prompt: sessionPrompt,
  170. },
  171. },
  172. } as any,
  173. state,
  174. );
  175. await expect(
  176. tool.execute({ prompt: 'Cancel me' }, {
  177. sessionID: 'ses_old',
  178. abort: controller.signal,
  179. } as any),
  180. ).rejects.toThrow('Prompt cancelled');
  181. expect(sessionAbort).toHaveBeenCalledWith({
  182. path: { id: 'ses_new' },
  183. query: { directory },
  184. });
  185. expect(state.isSubtaskSession('ses_new')).toBe(false);
  186. expect(sessionMessages).not.toHaveBeenCalled();
  187. } finally {
  188. fs.rmSync(directory, { recursive: true, force: true });
  189. }
  190. });
  191. test('blocks nested subtask calls from a subtask worker', async () => {
  192. const directory = makeTempDir();
  193. try {
  194. let nestedResult = '';
  195. const state = createSubtaskState();
  196. const tool = createSubtaskTool(
  197. {
  198. directory,
  199. client: {
  200. session: {
  201. abort: mock(async () => ({})),
  202. create: mock(async () => ({ data: { id: 'ses_subtask' } })),
  203. messages: mock(async () => ({
  204. data: [
  205. {
  206. info: { role: 'assistant' },
  207. parts: [{ type: 'text', text: 'done' }],
  208. },
  209. ],
  210. })),
  211. prompt: mock(async () => {
  212. nestedResult = String(
  213. await tool.execute({ prompt: 'nested subtask' }, {
  214. sessionID: 'ses_subtask',
  215. } as any),
  216. );
  217. }),
  218. },
  219. },
  220. } as any,
  221. state,
  222. new SubagentDepthTracker(),
  223. );
  224. await tool.execute({ prompt: 'outer subtask' }, {
  225. sessionID: 'ses_old',
  226. } as any);
  227. expect(nestedResult).toContain('Nested subtask is disabled');
  228. } finally {
  229. fs.rmSync(directory, { recursive: true, force: true });
  230. }
  231. });
  232. test('honors custom timeoutMs option', async () => {
  233. const directory = makeTempDir();
  234. try {
  235. const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
  236. const sessionPrompt = mock(() => new Promise(() => {}));
  237. const sessionMessages = mock(async () => ({ data: [] }));
  238. const sessionAbort = mock(async () => ({}));
  239. const state = createSubtaskState();
  240. const tool = createSubtaskTool(
  241. {
  242. directory,
  243. client: {
  244. session: {
  245. abort: sessionAbort,
  246. create: sessionCreate,
  247. messages: sessionMessages,
  248. prompt: sessionPrompt,
  249. },
  250. },
  251. } as any,
  252. state,
  253. undefined,
  254. { timeoutMs: 5 },
  255. );
  256. await expect(
  257. tool.execute({ prompt: 'Will time out' }, {
  258. sessionID: 'ses_old',
  259. } as any),
  260. ).rejects.toThrow('Prompt timed out after 5ms');
  261. expect(sessionAbort).toHaveBeenCalledWith({
  262. path: { id: 'ses_new' },
  263. query: { directory },
  264. });
  265. } finally {
  266. fs.rmSync(directory, { recursive: true, force: true });
  267. }
  268. });
  269. });
  270. describe('read_session tool', () => {
  271. test('formats session transcripts', async () => {
  272. const messages = mock(async () => ({
  273. data: [
  274. { info: { role: 'user' }, parts: [{ type: 'text', text: 'Hi' }] },
  275. {
  276. info: { role: 'assistant' },
  277. parts: [
  278. { type: 'text', text: 'Hello' },
  279. {
  280. type: 'tool',
  281. tool: 'read',
  282. state: { status: 'completed', title: 'Read file' },
  283. },
  284. ],
  285. },
  286. ],
  287. }));
  288. const state = createSubtaskState();
  289. state.markSession('ses_worker', 'ses_old');
  290. const result = await createReadSessionTool(
  291. { session: { messages } } as any,
  292. state,
  293. ).execute({ sessionID: 'ses_old' }, { sessionID: 'ses_worker' } as any);
  294. expect(result).toContain('## User');
  295. expect(result).toContain('Hi');
  296. expect(result).toContain('## Assistant');
  297. expect(result).toContain('[Tool: read] Read file');
  298. });
  299. test('blocks reads outside the source session', async () => {
  300. const state = createSubtaskState();
  301. state.markSession('ses_worker', 'ses_old');
  302. const messages = mock(async () => ({ data: [] }));
  303. const result = await createReadSessionTool(
  304. { session: { messages } } as any,
  305. state,
  306. ).execute({ sessionID: 'ses_other' }, { sessionID: 'ses_worker' } as any);
  307. expect(result).toContain('can only read the source session');
  308. expect(messages).not.toHaveBeenCalled();
  309. });
  310. });