agent-tower 0.4.15 → 0.4.16-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/dist/app.test.js +2 -0
  2. package/dist/app.test.js.map +1 -1
  3. package/dist/core/event-bus.d.ts +2 -0
  4. package/dist/core/event-bus.d.ts.map +1 -1
  5. package/dist/core/event-bus.js.map +1 -1
  6. package/dist/executors/__tests__/codex.executor.test.d.ts +2 -0
  7. package/dist/executors/__tests__/codex.executor.test.d.ts.map +1 -0
  8. package/dist/executors/__tests__/codex.executor.test.js +28 -0
  9. package/dist/executors/__tests__/codex.executor.test.js.map +1 -0
  10. package/dist/executors/codex.executor.d.ts +1 -0
  11. package/dist/executors/codex.executor.d.ts.map +1 -1
  12. package/dist/executors/codex.executor.js +19 -1
  13. package/dist/executors/codex.executor.js.map +1 -1
  14. package/dist/git/git-cli.d.ts +18 -1
  15. package/dist/git/git-cli.d.ts.map +1 -1
  16. package/dist/git/git-cli.js +17 -1
  17. package/dist/git/git-cli.js.map +1 -1
  18. package/dist/git/worktree.manager.d.ts +29 -2
  19. package/dist/git/worktree.manager.d.ts.map +1 -1
  20. package/dist/git/worktree.manager.js +137 -16
  21. package/dist/git/worktree.manager.js.map +1 -1
  22. package/dist/git/worktree.manager.test.d.ts +2 -0
  23. package/dist/git/worktree.manager.test.d.ts.map +1 -0
  24. package/dist/git/worktree.manager.test.js +104 -0
  25. package/dist/git/worktree.manager.test.js.map +1 -0
  26. package/dist/mcp/context.d.ts +3 -0
  27. package/dist/mcp/context.d.ts.map +1 -1
  28. package/dist/mcp/context.js +10 -1
  29. package/dist/mcp/context.js.map +1 -1
  30. package/dist/mcp/http-client.d.ts +24 -1
  31. package/dist/mcp/http-client.d.ts.map +1 -1
  32. package/dist/mcp/http-client.js +37 -3
  33. package/dist/mcp/http-client.js.map +1 -1
  34. package/dist/mcp/server.d.ts.map +1 -1
  35. package/dist/mcp/server.js +190 -0
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/middleware/tunnel-auth.d.ts.map +1 -1
  38. package/dist/middleware/tunnel-auth.js +2 -0
  39. package/dist/middleware/tunnel-auth.js.map +1 -1
  40. package/dist/output/__tests__/codex-parser.test.d.ts +2 -0
  41. package/dist/output/__tests__/codex-parser.test.d.ts.map +1 -0
  42. package/dist/output/__tests__/codex-parser.test.js +148 -0
  43. package/dist/output/__tests__/codex-parser.test.js.map +1 -0
  44. package/dist/output/codex-parser.d.ts +12 -0
  45. package/dist/output/codex-parser.d.ts.map +1 -1
  46. package/dist/output/codex-parser.js +129 -12
  47. package/dist/output/codex-parser.js.map +1 -1
  48. package/dist/routes/__tests__/attachments.test.d.ts +2 -0
  49. package/dist/routes/__tests__/attachments.test.d.ts.map +1 -0
  50. package/dist/routes/__tests__/attachments.test.js +86 -0
  51. package/dist/routes/__tests__/attachments.test.js.map +1 -0
  52. package/dist/routes/__tests__/filesystem.test.d.ts +2 -0
  53. package/dist/routes/__tests__/filesystem.test.d.ts.map +1 -0
  54. package/dist/routes/__tests__/filesystem.test.js +80 -0
  55. package/dist/routes/__tests__/filesystem.test.js.map +1 -0
  56. package/dist/routes/__tests__/previews.test.d.ts +2 -0
  57. package/dist/routes/__tests__/previews.test.d.ts.map +1 -0
  58. package/dist/routes/__tests__/previews.test.js +89 -0
  59. package/dist/routes/__tests__/previews.test.js.map +1 -0
  60. package/dist/routes/__tests__/tasks.test.d.ts +2 -0
  61. package/dist/routes/__tests__/tasks.test.d.ts.map +1 -0
  62. package/dist/routes/__tests__/tasks.test.js +72 -0
  63. package/dist/routes/__tests__/tasks.test.js.map +1 -0
  64. package/dist/routes/attachments.d.ts.map +1 -1
  65. package/dist/routes/attachments.js +36 -16
  66. package/dist/routes/attachments.js.map +1 -1
  67. package/dist/routes/filesystem.d.ts.map +1 -1
  68. package/dist/routes/filesystem.js +24 -3
  69. package/dist/routes/filesystem.js.map +1 -1
  70. package/dist/routes/index.d.ts.map +1 -1
  71. package/dist/routes/index.js +6 -0
  72. package/dist/routes/index.js.map +1 -1
  73. package/dist/routes/previews.d.ts +6 -0
  74. package/dist/routes/previews.d.ts.map +1 -0
  75. package/dist/routes/previews.js +413 -0
  76. package/dist/routes/previews.js.map +1 -0
  77. package/dist/routes/projects.d.ts.map +1 -1
  78. package/dist/routes/projects.js +1 -0
  79. package/dist/routes/projects.js.map +1 -1
  80. package/dist/routes/system.d.ts.map +1 -1
  81. package/dist/routes/system.js +35 -1
  82. package/dist/routes/system.js.map +1 -1
  83. package/dist/routes/tasks.js +2 -2
  84. package/dist/routes/tasks.js.map +1 -1
  85. package/dist/routes/team-runs.d.ts +11 -0
  86. package/dist/routes/team-runs.d.ts.map +1 -0
  87. package/dist/routes/team-runs.js +309 -0
  88. package/dist/routes/team-runs.js.map +1 -0
  89. package/dist/routes/tunnel.d.ts.map +1 -1
  90. package/dist/routes/tunnel.js +20 -0
  91. package/dist/routes/tunnel.js.map +1 -1
  92. package/dist/routes/workspaces.d.ts.map +1 -1
  93. package/dist/routes/workspaces.js +15 -1
  94. package/dist/routes/workspaces.js.map +1 -1
  95. package/dist/services/__tests__/preview.service.test.d.ts +2 -0
  96. package/dist/services/__tests__/preview.service.test.d.ts.map +1 -0
  97. package/dist/services/__tests__/preview.service.test.js +29 -0
  98. package/dist/services/__tests__/preview.service.test.js.map +1 -0
  99. package/dist/services/__tests__/session-manager.team-run.test.d.ts +2 -0
  100. package/dist/services/__tests__/session-manager.team-run.test.d.ts.map +1 -0
  101. package/dist/services/__tests__/session-manager.team-run.test.js +286 -0
  102. package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -0
  103. package/dist/services/__tests__/task.service.test.d.ts +2 -0
  104. package/dist/services/__tests__/task.service.test.d.ts.map +1 -0
  105. package/dist/services/__tests__/task.service.test.js +65 -0
  106. package/dist/services/__tests__/task.service.test.js.map +1 -0
  107. package/dist/services/__tests__/team-lock.service.test.d.ts +2 -0
  108. package/dist/services/__tests__/team-lock.service.test.d.ts.map +1 -0
  109. package/dist/services/__tests__/team-lock.service.test.js +81 -0
  110. package/dist/services/__tests__/team-lock.service.test.js.map +1 -0
  111. package/dist/services/__tests__/team-reconciler.service.test.d.ts +2 -0
  112. package/dist/services/__tests__/team-reconciler.service.test.d.ts.map +1 -0
  113. package/dist/services/__tests__/team-reconciler.service.test.js +1536 -0
  114. package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -0
  115. package/dist/services/__tests__/team-run.service.test.d.ts +2 -0
  116. package/dist/services/__tests__/team-run.service.test.d.ts.map +1 -0
  117. package/dist/services/__tests__/team-run.service.test.js +699 -0
  118. package/dist/services/__tests__/team-run.service.test.js.map +1 -0
  119. package/dist/services/__tests__/team-scheduler.service.test.d.ts +2 -0
  120. package/dist/services/__tests__/team-scheduler.service.test.d.ts.map +1 -0
  121. package/dist/services/__tests__/team-scheduler.service.test.js +1688 -0
  122. package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -0
  123. package/dist/services/__tests__/tunnel.service.test.d.ts +2 -0
  124. package/dist/services/__tests__/tunnel.service.test.d.ts.map +1 -0
  125. package/dist/services/__tests__/tunnel.service.test.js +138 -0
  126. package/dist/services/__tests__/tunnel.service.test.js.map +1 -0
  127. package/dist/services/__tests__/workspace.service.test.d.ts +2 -0
  128. package/dist/services/__tests__/workspace.service.test.d.ts.map +1 -0
  129. package/dist/services/__tests__/workspace.service.test.js +695 -0
  130. package/dist/services/__tests__/workspace.service.test.js.map +1 -0
  131. package/dist/services/attachment-context.d.ts +3 -0
  132. package/dist/services/attachment-context.d.ts.map +1 -0
  133. package/dist/services/attachment-context.js +34 -0
  134. package/dist/services/attachment-context.js.map +1 -0
  135. package/dist/services/preview.service.d.ts +19 -0
  136. package/dist/services/preview.service.d.ts.map +1 -0
  137. package/dist/services/preview.service.js +147 -0
  138. package/dist/services/preview.service.js.map +1 -0
  139. package/dist/services/project.service.d.ts +2 -0
  140. package/dist/services/project.service.d.ts.map +1 -1
  141. package/dist/services/project.service.js +87 -18
  142. package/dist/services/project.service.js.map +1 -1
  143. package/dist/services/session-manager.d.ts +43 -1
  144. package/dist/services/session-manager.d.ts.map +1 -1
  145. package/dist/services/session-manager.js +110 -2
  146. package/dist/services/session-manager.js.map +1 -1
  147. package/dist/services/task.service.d.ts +6 -0
  148. package/dist/services/task.service.d.ts.map +1 -1
  149. package/dist/services/task.service.js +15 -3
  150. package/dist/services/task.service.js.map +1 -1
  151. package/dist/services/team-lock.service.d.ts +25 -0
  152. package/dist/services/team-lock.service.d.ts.map +1 -0
  153. package/dist/services/team-lock.service.js +56 -0
  154. package/dist/services/team-lock.service.js.map +1 -0
  155. package/dist/services/team-reconciler.service.d.ts +44 -0
  156. package/dist/services/team-reconciler.service.d.ts.map +1 -0
  157. package/dist/services/team-reconciler.service.js +286 -0
  158. package/dist/services/team-reconciler.service.js.map +1 -0
  159. package/dist/services/team-run-events.d.ts +13 -0
  160. package/dist/services/team-run-events.d.ts.map +1 -0
  161. package/dist/services/team-run-events.js +27 -0
  162. package/dist/services/team-run-events.js.map +1 -0
  163. package/dist/services/team-run.service.d.ts +92 -0
  164. package/dist/services/team-run.service.d.ts.map +1 -0
  165. package/dist/services/team-run.service.js +835 -0
  166. package/dist/services/team-run.service.js.map +1 -0
  167. package/dist/services/team-scheduler.service.d.ts +104 -0
  168. package/dist/services/team-scheduler.service.d.ts.map +1 -0
  169. package/dist/services/team-scheduler.service.js +843 -0
  170. package/dist/services/team-scheduler.service.js.map +1 -0
  171. package/dist/services/tunnel.service.d.ts +31 -5
  172. package/dist/services/tunnel.service.d.ts.map +1 -1
  173. package/dist/services/tunnel.service.js +293 -32
  174. package/dist/services/tunnel.service.js.map +1 -1
  175. package/dist/services/workspace.service.d.ts +161 -7
  176. package/dist/services/workspace.service.d.ts.map +1 -1
  177. package/dist/services/workspace.service.js +396 -51
  178. package/dist/services/workspace.service.js.map +1 -1
  179. package/dist/socket/events.d.ts +1 -1
  180. package/dist/socket/events.d.ts.map +1 -1
  181. package/dist/socket/events.js.map +1 -1
  182. package/dist/socket/socket-gateway.d.ts.map +1 -1
  183. package/dist/socket/socket-gateway.js +5 -1
  184. package/dist/socket/socket-gateway.js.map +1 -1
  185. package/dist/web/assets/AgentDemoPage-Bf6labVB.js +1 -0
  186. package/dist/web/assets/{DemoPage-XwuS8vNB.js → DemoPage-DlfG47rV.js} +3 -3
  187. package/dist/web/assets/{GeneralSettingsPage-CliIgpwf.js → GeneralSettingsPage-DefqwzVn.js} +1 -1
  188. package/dist/web/assets/MemberAvatar-DVw_TedB.js +1 -0
  189. package/dist/web/assets/NotificationSettingsPage-C9h1U1Za.js +1 -0
  190. package/dist/web/assets/{ProfileSettingsPage-CkU_kZKG.js → ProfileSettingsPage-BkZE2yVP.js} +1 -1
  191. package/dist/web/assets/ProjectKanbanPage-B1Ckl1uY.js +89 -0
  192. package/dist/web/assets/ProjectSettingsPage-ByZ13awb.js +2 -0
  193. package/dist/web/assets/{ProviderSettingsPage-CfvdeoEU.js → ProviderSettingsPage-DSQYe8B6.js} +12 -12
  194. package/dist/web/assets/TeamSettingsPage-DUukJ_Ih.js +1 -0
  195. package/dist/web/assets/agent-tower-logo-COx9gy77.png +0 -0
  196. package/dist/web/assets/{button-BWFTEdOr.js → button-Bpm98eOV.js} +1 -1
  197. package/dist/web/assets/{chevron-down-CuPdBAx-.js → chevron-down-DSKKXCi8.js} +1 -1
  198. package/dist/web/assets/{chevron-right-Cs8vYTMn.js → chevron-right-CZdDV9GU.js} +1 -1
  199. package/dist/web/assets/chevron-up-gnnlwvYe.js +1 -0
  200. package/dist/web/assets/{circle-check-BXZTzqw0.js → circle-check-DeD_VuLK.js} +1 -1
  201. package/dist/web/assets/{code-block-OCS4YCEC-BxUpvXK_.js → code-block-OCS4YCEC-BrGjkdjS.js} +1 -1
  202. package/dist/web/assets/{confirm-dialog-CDLHRthd.js → confirm-dialog-CEVVvAcE.js} +1 -1
  203. package/dist/web/assets/folder-picker-ZBQlFEWL.js +1 -0
  204. package/dist/web/assets/index-B5g4V0NU.js +13 -0
  205. package/dist/web/assets/index-ltjI8o6A.css +1 -0
  206. package/dist/web/assets/loader-circle-GMfBClX0.js +1 -0
  207. package/dist/web/assets/{log-adapter-CeKrvZcz.js → log-adapter-DKKM3sxS.js} +1 -1
  208. package/dist/web/assets/{mermaid-NOHMQCX5-BOSwJqP0.js → mermaid-NOHMQCX5-D5USvUiZ.js} +44 -44
  209. package/dist/web/assets/modal-JMpuh-LG.js +1 -0
  210. package/dist/web/assets/{pencil-BMxBxIhw.js → pencil-QrCW47nn.js} +1 -1
  211. package/dist/web/assets/{select-BUmRG0LY.js → select-CINRzLiE.js} +1 -1
  212. package/dist/web/assets/upload-vFxZxKHo.js +1 -0
  213. package/dist/web/assets/{use-profiles-C1vlPE-2.js → use-profiles-SrVWPYv0.js} +1 -1
  214. package/dist/web/assets/{use-providers-Cdxr4Jbz.js → use-providers-BihMydl0.js} +1 -1
  215. package/dist/web/avatars/presets/avatar-preset-01-developer.png +0 -0
  216. package/dist/web/avatars/presets/avatar-preset-02-architect.png +0 -0
  217. package/dist/web/avatars/presets/avatar-preset-03-tester.png +0 -0
  218. package/dist/web/avatars/presets/avatar-preset-04-devops.png +0 -0
  219. package/dist/web/avatars/presets/avatar-preset-05-data-scientist.png +0 -0
  220. package/dist/web/avatars/presets/avatar-preset-06-frontend.png +0 -0
  221. package/dist/web/avatars/presets/avatar-preset-07-backend.png +0 -0
  222. package/dist/web/avatars/presets/avatar-preset-08-security.png +0 -0
  223. package/dist/web/avatars/presets/avatar-preset-09-project-manager.png +0 -0
  224. package/dist/web/avatars/presets/avatar-preset-10-product-manager.png +0 -0
  225. package/dist/web/avatars/presets/avatar-preset-11-scrum-master.png +0 -0
  226. package/dist/web/avatars/presets/avatar-preset-12-tech-lead.png +0 -0
  227. package/dist/web/avatars/presets/avatar-preset-13-coordinator.png +0 -0
  228. package/dist/web/avatars/presets/avatar-preset-14-mentor.png +0 -0
  229. package/dist/web/avatars/presets/avatar-preset-15-reviewer.png +0 -0
  230. package/dist/web/avatars/presets/avatar-preset-16-ui-designer.png +0 -0
  231. package/dist/web/avatars/presets/avatar-preset-17-ux-researcher.png +0 -0
  232. package/dist/web/avatars/presets/avatar-preset-18-documenter.png +0 -0
  233. package/dist/web/avatars/presets/avatar-preset-19-translator.png +0 -0
  234. package/dist/web/avatars/presets/avatar-preset-20-analyst.png +0 -0
  235. package/dist/web/avatars/presets/avatar-preset-21-consultant.png +0 -0
  236. package/dist/web/avatars/presets/avatar-preset-22-creative-director.png +0 -0
  237. package/dist/web/avatars/presets/avatar-preset-23-support.png +0 -0
  238. package/dist/web/avatars/presets/avatar-preset-24-assistant.png +0 -0
  239. package/dist/web/avatars/presets/avatar-preset-25-robot.png +0 -0
  240. package/dist/web/avatars/presets/avatar-preset-grid.png +0 -0
  241. package/dist/web/index.html +2 -2
  242. package/node_modules/@agent-tower/shared/dist/socket/events.d.ts +10 -0
  243. package/node_modules/@agent-tower/shared/dist/socket/events.d.ts.map +1 -1
  244. package/node_modules/@agent-tower/shared/dist/socket/events.js +1 -0
  245. package/node_modules/@agent-tower/shared/dist/socket/events.js.map +1 -1
  246. package/node_modules/@agent-tower/shared/dist/types.d.ts +161 -0
  247. package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
  248. package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
  249. package/node_modules/@prisma/client/.prisma/client/default.d.ts +1 -0
  250. package/node_modules/@prisma/client/.prisma/client/default.js +1 -0
  251. package/node_modules/@prisma/client/.prisma/client/edge.d.ts +1 -0
  252. package/node_modules/@prisma/client/.prisma/client/edge.js +396 -0
  253. package/node_modules/@prisma/client/.prisma/client/index-browser.js +385 -0
  254. package/node_modules/@prisma/client/.prisma/client/index.d.ts +26996 -0
  255. package/node_modules/@prisma/client/.prisma/client/index.js +421 -0
  256. package/node_modules/@prisma/client/.prisma/client/libquery_engine-darwin-arm64.dylib.node +0 -0
  257. package/node_modules/@prisma/client/.prisma/client/package.json +97 -0
  258. package/node_modules/@prisma/client/.prisma/client/query_engine-windows.dll.node +0 -0
  259. package/node_modules/@prisma/client/.prisma/client/schema.prisma +296 -0
  260. package/node_modules/@prisma/client/.prisma/client/wasm.d.ts +1 -0
  261. package/node_modules/@prisma/client/.prisma/client/wasm.js +385 -0
  262. package/node_modules/@prisma/client/package.json +3 -2
  263. package/package.json +2 -1
  264. package/prisma/migrations/20260515000000_add_workspace_preview_target/migration.sql +2 -0
  265. package/prisma/migrations/20260518000000_add_team_run_collaboration/migration.sql +150 -0
  266. package/prisma/migrations/20260522000000_add_team_member_session_policy/migration.sql +2 -0
  267. package/prisma/migrations/20260526000000_add_team_run_main_and_dedicated_workspaces/migration.sql +21 -0
  268. package/prisma/schema.prisma +147 -1
  269. package/dist/web/assets/AgentDemoPage-ClnGPAV9.js +0 -1
  270. package/dist/web/assets/NotificationSettingsPage-y3vhVgPv.js +0 -1
  271. package/dist/web/assets/ProjectKanbanPage-BddzfZRV.js +0 -87
  272. package/dist/web/assets/ProjectSettingsPage-B6xhbziO.js +0 -2
  273. package/dist/web/assets/circle-alert-EUyZcWhp.js +0 -1
  274. package/dist/web/assets/folder-picker-CUbhsnhi.js +0 -1
  275. package/dist/web/assets/index-BGvfX18x.css +0 -1
  276. package/dist/web/assets/index-CHN8jahE.js +0 -13
  277. package/dist/web/assets/loader-circle-BHzDVpxt.js +0 -1
  278. package/dist/web/assets/modal-D_AU4URz.js +0 -1
  279. package/dist/web/assets/use-projects-Bcd5hIOY.js +0 -1
@@ -0,0 +1,1536 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import Fastify from 'fastify';
7
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
8
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { TaskStatus } from '../../types/index.js';
11
+ import { EventBus } from '../../core/event-bus.js';
12
+ import { TeamLockService } from '../team-lock.service.js';
13
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-tower-team-reconciler-'));
14
+ const dbPath = path.join(testDir, 'test.db');
15
+ process.env.AGENT_TOWER_DATABASE_URL = `file:${dbPath}`;
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+ const serverRoot = path.resolve(__dirname, '../../..');
19
+ const schemaPath = path.join(serverRoot, 'prisma/schema.prisma');
20
+ let prisma;
21
+ let TeamReconcilerService;
22
+ let SessionManager;
23
+ let TEAM_ROOM_REPLY_REMINDER;
24
+ let createMcpServer;
25
+ let teamRunRoutes;
26
+ let workRequestSequence = 0;
27
+ const TEAM_RUN_MISMATCH_ERROR = 'team_run_id does not match the current TeamRun session.';
28
+ const TEAM_RUN_ENV_KEYS = [
29
+ 'AGENT_TOWER_TEAM_RUN_ID',
30
+ 'AGENT_TOWER_MEMBER_ID',
31
+ 'AGENT_TOWER_INVOCATION_ID',
32
+ 'AGENT_TOWER_SESSION_ID',
33
+ ];
34
+ const capabilities = {
35
+ readRoom: true,
36
+ postRoomMessage: true,
37
+ mentionMembers: true,
38
+ stopMemberWork: false,
39
+ markReadyForReview: false,
40
+ readFiles: true,
41
+ writeFiles: true,
42
+ runCommands: false,
43
+ readDiff: true,
44
+ mergeWorkspace: false,
45
+ };
46
+ function stringifyJson(value) {
47
+ return JSON.stringify(value);
48
+ }
49
+ function captureTeamRunEnv() {
50
+ return TEAM_RUN_ENV_KEYS.reduce((snapshot, key) => ({
51
+ ...snapshot,
52
+ [key]: process.env[key],
53
+ }), {});
54
+ }
55
+ function setTeamRunEnv(values) {
56
+ for (const key of TEAM_RUN_ENV_KEYS) {
57
+ if (!(key in values)) {
58
+ continue;
59
+ }
60
+ const value = values[key];
61
+ if (value === undefined) {
62
+ delete process.env[key];
63
+ }
64
+ else {
65
+ process.env[key] = value;
66
+ }
67
+ }
68
+ }
69
+ function restoreTeamRunEnv(snapshot) {
70
+ setTeamRunEnv(snapshot);
71
+ }
72
+ function getMcpToolText(result) {
73
+ const content = result.content;
74
+ return content?.[0]?.type === 'text' ? content[0].text ?? '' : '';
75
+ }
76
+ function createSchedulerMock(lockService) {
77
+ return {
78
+ releaseInvocationLocks: vi.fn((invocationId) => {
79
+ lockService.releaseByOwner(invocationId);
80
+ }),
81
+ startNextSessions: vi.fn(async () => []),
82
+ };
83
+ }
84
+ function createMessengerMock() {
85
+ return {
86
+ sendMessage: vi.fn(async () => null),
87
+ };
88
+ }
89
+ function asWorkRequest(value) {
90
+ return value;
91
+ }
92
+ function asAgentInvocations(value) {
93
+ return value;
94
+ }
95
+ function createRouteSchedulerMock() {
96
+ const startedTeamRunIds = [];
97
+ const scheduler = {
98
+ startedTeamRunIds,
99
+ };
100
+ scheduler.startNextSessions = vi.fn(async (teamRunId) => {
101
+ startedTeamRunIds.push(teamRunId);
102
+ const workRequests = await prisma.workRequest.findMany({
103
+ where: { teamRunId, status: 'QUEUED' },
104
+ orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
105
+ });
106
+ const invocations = [];
107
+ for (const workRequest of workRequests) {
108
+ await prisma.workRequest.update({
109
+ where: { id: workRequest.id },
110
+ data: { status: 'STARTED' },
111
+ });
112
+ invocations.push(await prisma.agentInvocation.create({
113
+ data: {
114
+ teamRunId,
115
+ workRequestId: workRequest.id,
116
+ memberId: workRequest.targetMemberId,
117
+ workspaceId: null,
118
+ sessionId: null,
119
+ status: 'RUNNING',
120
+ },
121
+ }));
122
+ }
123
+ return asAgentInvocations(invocations);
124
+ });
125
+ scheduler.approveWorkRequestAndStartNext = vi.fn(async (workRequestId) => {
126
+ const workRequest = await prisma.workRequest.update({
127
+ where: { id: workRequestId },
128
+ data: { status: 'QUEUED' },
129
+ });
130
+ const startedInvocations = await scheduler.startNextSessions(workRequest.teamRunId);
131
+ return { workRequest: asWorkRequest(workRequest), startedInvocations };
132
+ });
133
+ scheduler.rejectWorkRequest = vi.fn(async (workRequestId) => prisma.workRequest.update({
134
+ where: { id: workRequestId },
135
+ data: { status: 'REJECTED' },
136
+ }).then(asWorkRequest));
137
+ scheduler.cancelWorkRequest = vi.fn(async (workRequestId) => prisma.workRequest.update({
138
+ where: { id: workRequestId },
139
+ data: { status: 'CANCELLED' },
140
+ }).then(asWorkRequest));
141
+ scheduler.stopMemberWork = vi.fn(async () => ({
142
+ stoppedSessionIds: [],
143
+ cancelledInvocationIds: [],
144
+ cancelledWorkRequestIds: [],
145
+ startedInvocations: [],
146
+ }));
147
+ return scheduler;
148
+ }
149
+ async function createFixture(options = {}) {
150
+ const project = await prisma.project.create({
151
+ data: {
152
+ name: 'Team reconciler project',
153
+ repoPath: testDir,
154
+ },
155
+ });
156
+ const task = await prisma.task.create({
157
+ data: {
158
+ title: 'Team reconciler task',
159
+ status: options.taskStatus ?? TaskStatus.IN_PROGRESS,
160
+ projectId: project.id,
161
+ },
162
+ });
163
+ const workspace = await prisma.workspace.create({
164
+ data: {
165
+ taskId: task.id,
166
+ branchName: 'team-shared',
167
+ worktreePath: testDir,
168
+ status: 'ACTIVE',
169
+ },
170
+ });
171
+ const teamRun = await prisma.teamRun.create({
172
+ data: {
173
+ taskId: task.id,
174
+ mode: options.teamRunMode ?? 'AUTO',
175
+ },
176
+ });
177
+ const members = [];
178
+ for (let index = 0; index < (options.memberCount ?? 1); index += 1) {
179
+ members.push(await prisma.teamMember.create({
180
+ data: {
181
+ teamRunId: teamRun.id,
182
+ presetId: null,
183
+ name: `Member ${index + 1}`,
184
+ aliases: stringifyJson([`member-${index + 1}`]),
185
+ providerId: `provider-${index + 1}`,
186
+ rolePrompt: `Role ${index + 1}`,
187
+ capabilities: stringifyJson(capabilities),
188
+ workspacePolicy: 'shared',
189
+ triggerPolicy: options.triggerPolicies?.[index] ?? 'MENTION_ONLY',
190
+ sessionPolicy: 'new_per_request',
191
+ avatar: null,
192
+ },
193
+ }));
194
+ }
195
+ return { project, task, workspace, teamRun, members };
196
+ }
197
+ async function createMemberPreset(options = {}) {
198
+ const name = options.name ?? 'Leader';
199
+ return prisma.memberPreset.create({
200
+ data: {
201
+ name,
202
+ aliases: stringifyJson([name.toLowerCase()]),
203
+ providerId: `provider-${name.toLowerCase()}`,
204
+ rolePrompt: `${name} role`,
205
+ capabilities: stringifyJson(capabilities),
206
+ workspacePolicy: 'shared',
207
+ triggerPolicy: options.triggerPolicy ?? 'USER_MESSAGES',
208
+ sessionPolicy: 'new_per_request',
209
+ avatar: null,
210
+ },
211
+ });
212
+ }
213
+ async function createWorkRequest(options) {
214
+ return prisma.workRequest.create({
215
+ data: {
216
+ teamRunId: options.teamRunId,
217
+ requesterMemberId: null,
218
+ requesterType: 'user',
219
+ targetMemberId: options.targetMemberId,
220
+ triggerMessageId: `message-${Math.random().toString(16).slice(2)}`,
221
+ instruction: options.instruction ?? 'Please do the work',
222
+ ifBusy: 'queue',
223
+ cancelQueued: false,
224
+ status: options.status ?? 'STARTED',
225
+ createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, workRequestSequence++)),
226
+ },
227
+ });
228
+ }
229
+ async function createRunningInvocation(options) {
230
+ const session = options.sessionId
231
+ ? await prisma.session.findUniqueOrThrow({ where: { id: options.sessionId } })
232
+ : await prisma.session.create({
233
+ data: {
234
+ workspaceId: options.workspaceId,
235
+ agentType: 'CODEX',
236
+ providerId: 'provider-1',
237
+ prompt: 'Do the work',
238
+ status: 'COMPLETED',
239
+ },
240
+ });
241
+ return prisma.agentInvocation.create({
242
+ data: {
243
+ teamRunId: options.teamRunId,
244
+ workRequestId: options.workRequestId,
245
+ memberId: options.memberId,
246
+ workspaceId: options.workspaceId ?? session.workspaceId,
247
+ sessionId: session.id,
248
+ status: options.status ?? 'RUNNING',
249
+ roomReplyReminderCount: options.roomReplyReminderCount ?? 0,
250
+ },
251
+ });
252
+ }
253
+ describe('TeamReconcilerService', () => {
254
+ let lockService;
255
+ let scheduler;
256
+ let messenger;
257
+ let eventBus;
258
+ let service;
259
+ beforeAll(async () => {
260
+ execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
261
+ cwd: serverRoot,
262
+ env: { ...process.env, AGENT_TOWER_DATABASE_URL: `file:${dbPath}` },
263
+ stdio: 'pipe',
264
+ });
265
+ const utilsModule = await import('../../utils/index.js');
266
+ const reconcilerModule = await import('../team-reconciler.service.js');
267
+ const sessionManagerModule = await import('../session-manager.js');
268
+ const mcpServerModule = await import('../../mcp/server.js');
269
+ const teamRunRoutesModule = await import('../../routes/team-runs.js');
270
+ prisma = utilsModule.prisma;
271
+ TeamReconcilerService = reconcilerModule.TeamReconcilerService;
272
+ SessionManager = sessionManagerModule.SessionManager;
273
+ TEAM_ROOM_REPLY_REMINDER = reconcilerModule.TEAM_ROOM_REPLY_REMINDER;
274
+ createMcpServer = mcpServerModule.createMcpServer;
275
+ teamRunRoutes = teamRunRoutesModule.teamRunRoutes;
276
+ });
277
+ beforeEach(async () => {
278
+ vi.restoreAllMocks();
279
+ workRequestSequence = 0;
280
+ lockService = new TeamLockService();
281
+ scheduler = createSchedulerMock(lockService);
282
+ messenger = createMessengerMock();
283
+ eventBus = new EventBus();
284
+ service = new TeamReconcilerService({
285
+ scheduler,
286
+ sessionMessenger: messenger,
287
+ eventBus,
288
+ now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
289
+ reminderDelaysMs: [1_000, 2_000, 4_000],
290
+ maxRoomReplyReminders: 3,
291
+ scheduleReminders: false,
292
+ });
293
+ await prisma.agentInvocation.deleteMany();
294
+ await prisma.workRequest.deleteMany();
295
+ await prisma.roomMessage.deleteMany();
296
+ await prisma.teamMember.deleteMany();
297
+ await prisma.teamRun.deleteMany();
298
+ await prisma.teamTemplateMember.deleteMany();
299
+ await prisma.teamTemplate.deleteMany();
300
+ await prisma.memberPreset.deleteMany();
301
+ await prisma.session.deleteMany();
302
+ await prisma.workspace.deleteMany();
303
+ await prisma.task.deleteMany();
304
+ await prisma.project.deleteMany();
305
+ });
306
+ afterAll(async () => {
307
+ vi.restoreAllMocks();
308
+ await prisma.$disconnect();
309
+ fs.rmSync(testDir, { recursive: true, force: true });
310
+ });
311
+ it('marks invocation completed and releases locks when a RoomMessage exists for the invocation', async () => {
312
+ const { workspace, teamRun, members } = await createFixture();
313
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
314
+ const invocation = await createRunningInvocation({
315
+ teamRunId: teamRun.id,
316
+ workRequestId: request.id,
317
+ memberId: members[0].id,
318
+ workspaceId: workspace.id,
319
+ });
320
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
321
+ await prisma.roomMessage.create({
322
+ data: {
323
+ teamRunId: teamRun.id,
324
+ senderType: 'agent',
325
+ senderId: members[0].id,
326
+ senderInvocationId: invocation.id,
327
+ kind: 'chat',
328
+ content: 'Implemented the change',
329
+ mentions: '[]',
330
+ workRequestIds: '[]',
331
+ artifactRefs: '[]',
332
+ attachmentIds: '[]',
333
+ },
334
+ });
335
+ await service.handleSessionExit(invocation.sessionId);
336
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
337
+ status: 'COMPLETED',
338
+ nextRoomReplyReminderAt: null,
339
+ });
340
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
341
+ expect(lockService.listLocks()).toEqual([]);
342
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
343
+ });
344
+ it('marks invocation waiting, increments reminder count, and sends a reminder when no RoomMessage exists', async () => {
345
+ const { workspace, teamRun, members } = await createFixture();
346
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
347
+ const invocation = await createRunningInvocation({
348
+ teamRunId: teamRun.id,
349
+ workRequestId: request.id,
350
+ memberId: members[0].id,
351
+ workspaceId: workspace.id,
352
+ });
353
+ await service.handleSessionExit(invocation.sessionId);
354
+ const reloaded = await prisma.agentInvocation.findUniqueOrThrow({ where: { id: invocation.id } });
355
+ expect(reloaded.status).toBe('WAITING_ROOM_REPLY');
356
+ expect(reloaded.roomReplyReminderCount).toBe(1);
357
+ expect(reloaded.nextRoomReplyReminderAt?.toISOString()).toBe('2026-01-01T00:00:01.000Z');
358
+ expect(messenger.sendMessage).toHaveBeenCalledWith(invocation.sessionId, TEAM_ROOM_REPLY_REMINDER);
359
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
360
+ });
361
+ it('does not send another reminder before the backoff time is due', async () => {
362
+ const { workspace, teamRun, members } = await createFixture();
363
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
364
+ const invocation = await createRunningInvocation({
365
+ teamRunId: teamRun.id,
366
+ workRequestId: request.id,
367
+ memberId: members[0].id,
368
+ workspaceId: workspace.id,
369
+ status: 'WAITING_ROOM_REPLY',
370
+ roomReplyReminderCount: 1,
371
+ });
372
+ await prisma.agentInvocation.update({
373
+ where: { id: invocation.id },
374
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)) },
375
+ });
376
+ await service.reconcileInvocation(invocation.id);
377
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
378
+ status: 'WAITING_ROOM_REPLY',
379
+ roomReplyReminderCount: 1,
380
+ });
381
+ expect(messenger.sendMessage).not.toHaveBeenCalled();
382
+ });
383
+ it('marks invocation failed and releases locks when max due reminders are reached without a RoomMessage', async () => {
384
+ const { workspace, teamRun, members } = await createFixture();
385
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
386
+ const invocation = await createRunningInvocation({
387
+ teamRunId: teamRun.id,
388
+ workRequestId: request.id,
389
+ memberId: members[0].id,
390
+ workspaceId: workspace.id,
391
+ status: 'WAITING_ROOM_REPLY',
392
+ roomReplyReminderCount: 2,
393
+ });
394
+ await prisma.agentInvocation.update({
395
+ where: { id: invocation.id },
396
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2025, 11, 31, 23, 59, 59)) },
397
+ });
398
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
399
+ await expect(service.reconcileDueRoomReplyReminders()).resolves.toBe(1);
400
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
401
+ status: 'WAITING_ROOM_REPLY',
402
+ roomReplyReminderCount: 3,
403
+ });
404
+ expect(messenger.sendMessage).toHaveBeenCalledTimes(1);
405
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
406
+ await prisma.agentInvocation.update({
407
+ where: { id: invocation.id },
408
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2025, 11, 31, 23, 59, 59)) },
409
+ });
410
+ await expect(service.reconcileDueRoomReplyReminders()).resolves.toBe(1);
411
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
412
+ status: 'FAILED',
413
+ nextRoomReplyReminderAt: null,
414
+ });
415
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
416
+ expect(lockService.listLocks()).toEqual([]);
417
+ });
418
+ it('moves an idle TeamRun task from IN_PROGRESS to IN_REVIEW and writes reviewReason', async () => {
419
+ const { workspace, task, teamRun, members } = await createFixture({ taskStatus: TaskStatus.IN_PROGRESS });
420
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
421
+ const invocation = await createRunningInvocation({
422
+ teamRunId: teamRun.id,
423
+ workRequestId: request.id,
424
+ memberId: members[0].id,
425
+ workspaceId: workspace.id,
426
+ });
427
+ await prisma.roomMessage.create({
428
+ data: {
429
+ teamRunId: teamRun.id,
430
+ senderType: 'agent',
431
+ senderId: members[0].id,
432
+ senderInvocationId: invocation.id,
433
+ kind: 'chat',
434
+ content: 'Done',
435
+ mentions: '[]',
436
+ workRequestIds: '[]',
437
+ artifactRefs: '[]',
438
+ attachmentIds: '[]',
439
+ },
440
+ });
441
+ const taskUpdates = [];
442
+ eventBus.on('task:updated', (payload) => taskUpdates.push(payload));
443
+ await service.handleSessionExit(invocation.sessionId);
444
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
445
+ status: TaskStatus.IN_REVIEW,
446
+ });
447
+ await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
448
+ reviewReason: 'TEAM_QUIESCENT',
449
+ });
450
+ expect(taskUpdates).toEqual([
451
+ { taskId: task.id, projectId: expect.any(String), status: TaskStatus.IN_REVIEW },
452
+ ]);
453
+ });
454
+ it('cancels invocation, releases locks, starts queued work, and advances idle TeamRun on session stop', async () => {
455
+ const { workspace, task, teamRun, members } = await createFixture({ taskStatus: TaskStatus.IN_PROGRESS });
456
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
457
+ const invocation = await createRunningInvocation({
458
+ teamRunId: teamRun.id,
459
+ workRequestId: request.id,
460
+ memberId: members[0].id,
461
+ workspaceId: workspace.id,
462
+ status: 'RUNNING',
463
+ });
464
+ await prisma.agentInvocation.update({
465
+ where: { id: invocation.id },
466
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)) },
467
+ });
468
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
469
+ await expect(service.handleSessionStopped(invocation.sessionId)).resolves.toBe(true);
470
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
471
+ status: 'CANCELLED',
472
+ nextRoomReplyReminderAt: null,
473
+ });
474
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
475
+ expect(lockService.listLocks()).toEqual([]);
476
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
477
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
478
+ status: TaskStatus.IN_REVIEW,
479
+ });
480
+ await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
481
+ reviewReason: 'TEAM_QUIESCENT',
482
+ });
483
+ });
484
+ it('keeps stopped TeamRun out of review when queued work remains', async () => {
485
+ const { workspace, task, teamRun, members } = await createFixture({
486
+ taskStatus: TaskStatus.IN_PROGRESS,
487
+ memberCount: 2,
488
+ });
489
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
490
+ const invocation = await createRunningInvocation({
491
+ teamRunId: teamRun.id,
492
+ workRequestId: request.id,
493
+ memberId: members[0].id,
494
+ workspaceId: workspace.id,
495
+ status: 'RUNNING',
496
+ });
497
+ await createWorkRequest({
498
+ teamRunId: teamRun.id,
499
+ targetMemberId: members[1].id,
500
+ status: 'QUEUED',
501
+ });
502
+ await service.handleSessionStopped(invocation.sessionId);
503
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
504
+ status: 'CANCELLED',
505
+ });
506
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
507
+ status: TaskStatus.IN_PROGRESS,
508
+ });
509
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
510
+ });
511
+ it.each([
512
+ ['queued work request', async (teamRunId, memberId) => {
513
+ await createWorkRequest({ teamRunId, targetMemberId: memberId, status: 'QUEUED' });
514
+ }],
515
+ ['pending approval work request', async (teamRunId, memberId) => {
516
+ await createWorkRequest({ teamRunId, targetMemberId: memberId, status: 'PENDING_APPROVAL' });
517
+ }],
518
+ ['running invocation', async (teamRunId, memberId, workspaceId) => {
519
+ const request = await createWorkRequest({ teamRunId, targetMemberId: memberId });
520
+ await createRunningInvocation({ teamRunId, workRequestId: request.id, memberId, workspaceId, status: 'RUNNING' });
521
+ }],
522
+ ['waiting invocation', async (teamRunId, memberId, workspaceId) => {
523
+ const request = await createWorkRequest({ teamRunId, targetMemberId: memberId });
524
+ await createRunningInvocation({
525
+ teamRunId,
526
+ workRequestId: request.id,
527
+ memberId,
528
+ workspaceId,
529
+ status: 'WAITING_ROOM_REPLY',
530
+ });
531
+ }],
532
+ ])('does not move the task to review while the TeamRun still has %s', async (_caseName, arrange) => {
533
+ const { workspace, task, teamRun, members } = await createFixture({
534
+ taskStatus: TaskStatus.IN_PROGRESS,
535
+ memberCount: 2,
536
+ });
537
+ const completedRequest = await createWorkRequest({
538
+ teamRunId: teamRun.id,
539
+ targetMemberId: members[0].id,
540
+ status: 'STARTED',
541
+ });
542
+ await createRunningInvocation({
543
+ teamRunId: teamRun.id,
544
+ workRequestId: completedRequest.id,
545
+ memberId: members[0].id,
546
+ workspaceId: workspace.id,
547
+ status: 'COMPLETED',
548
+ });
549
+ await arrange(teamRun.id, members[1].id, workspace.id);
550
+ const advanced = await service.maybeAdvanceTeamRunToReview(teamRun.id);
551
+ expect(advanced).toBe(false);
552
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
553
+ status: TaskStatus.IN_PROGRESS,
554
+ });
555
+ });
556
+ it('posts a room message from MCP env identity and creates WorkRequests through mentions', async () => {
557
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
558
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
559
+ const previousInvocationId = process.env.AGENT_TOWER_INVOCATION_ID;
560
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 2 });
561
+ const request = await createWorkRequest({
562
+ teamRunId: teamRun.id,
563
+ targetMemberId: members[0].id,
564
+ status: 'STARTED',
565
+ });
566
+ const invocation = await createRunningInvocation({
567
+ teamRunId: teamRun.id,
568
+ workRequestId: request.id,
569
+ memberId: members[0].id,
570
+ workspaceId: workspace.id,
571
+ });
572
+ const app = Fastify({ logger: false });
573
+ try {
574
+ process.env.AGENT_TOWER_TEAM_RUN_ID = teamRun.id;
575
+ process.env.AGENT_TOWER_MEMBER_ID = members[0].id;
576
+ process.env.AGENT_TOWER_INVOCATION_ID = invocation.id;
577
+ await app.register(teamRunRoutes, { prefix: '/api' });
578
+ await app.listen({ port: 0, host: '127.0.0.1' });
579
+ const address = app.server.address();
580
+ if (!address || typeof address === 'string') {
581
+ throw new Error('Failed to start test server');
582
+ }
583
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
584
+ const client = new Client({ name: 'team-room-test-client', version: '0.1.0' });
585
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
586
+ await server.connect(serverTransport);
587
+ await client.connect(clientTransport);
588
+ const result = await client.callTool({
589
+ name: 'post_room_message',
590
+ arguments: {
591
+ content: 'Implemented the parser and please review it',
592
+ mentions: [{ memberId: members[1].id }],
593
+ },
594
+ });
595
+ await client.close();
596
+ await server.close();
597
+ expect(result.isError).not.toBe(true);
598
+ const resultContent = result.content;
599
+ const messageText = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
600
+ const message = JSON.parse(messageText);
601
+ expect(message).toMatchObject({
602
+ senderType: 'agent',
603
+ senderInvocationId: invocation.id,
604
+ });
605
+ await expect(prisma.roomMessage.findUnique({ where: { id: message.id } })).resolves.toMatchObject({
606
+ senderType: 'agent',
607
+ senderId: members[0].id,
608
+ senderInvocationId: invocation.id,
609
+ });
610
+ let createdRequestId = '';
611
+ await vi.waitFor(async () => {
612
+ const createdRequest = await prisma.workRequest.findFirst({
613
+ where: {
614
+ teamRunId: teamRun.id,
615
+ triggerMessageId: message.id,
616
+ targetMemberId: members[1].id,
617
+ },
618
+ });
619
+ expect(createdRequest).toMatchObject({
620
+ requesterType: 'agent',
621
+ requesterMemberId: members[0].id,
622
+ status: 'STARTED',
623
+ });
624
+ createdRequestId = createdRequest.id;
625
+ });
626
+ await vi.waitFor(async () => {
627
+ await expect(prisma.agentInvocation.findFirst({
628
+ where: { workRequestId: createdRequestId },
629
+ })).resolves.toMatchObject({
630
+ memberId: members[1].id,
631
+ sessionId: null,
632
+ status: 'FAILED',
633
+ });
634
+ });
635
+ }
636
+ finally {
637
+ if (previousTeamRunId === undefined) {
638
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
639
+ }
640
+ else {
641
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
642
+ }
643
+ if (previousMemberId === undefined) {
644
+ delete process.env.AGENT_TOWER_MEMBER_ID;
645
+ }
646
+ else {
647
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
648
+ }
649
+ if (previousInvocationId === undefined) {
650
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
651
+ }
652
+ else {
653
+ process.env.AGENT_TOWER_INVOCATION_ID = previousInvocationId;
654
+ }
655
+ await app.close();
656
+ }
657
+ });
658
+ it('posts a room message from MCP workspace context identity when MCP env identity is missing', async () => {
659
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
660
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
661
+ const previousInvocationId = process.env.AGENT_TOWER_INVOCATION_ID;
662
+ const previousSessionId = process.env.AGENT_TOWER_SESSION_ID;
663
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 2 });
664
+ const request = await createWorkRequest({
665
+ teamRunId: teamRun.id,
666
+ targetMemberId: members[0].id,
667
+ status: 'STARTED',
668
+ });
669
+ const invocation = await createRunningInvocation({
670
+ teamRunId: teamRun.id,
671
+ workRequestId: request.id,
672
+ memberId: members[0].id,
673
+ workspaceId: workspace.id,
674
+ status: 'RUNNING',
675
+ });
676
+ const contextWorktreePath = fs.realpathSync(workspace.worktreePath);
677
+ await prisma.workspace.update({
678
+ where: { id: workspace.id },
679
+ data: { worktreePath: contextWorktreePath },
680
+ });
681
+ const app = Fastify({ logger: false });
682
+ try {
683
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
684
+ delete process.env.AGENT_TOWER_MEMBER_ID;
685
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
686
+ delete process.env.AGENT_TOWER_SESSION_ID;
687
+ await app.register((await import('../../routes/system.js')).systemRoutes, { prefix: '/api' });
688
+ await app.register(teamRunRoutes, { prefix: '/api' });
689
+ await app.listen({ port: 0, host: '127.0.0.1' });
690
+ const address = app.server.address();
691
+ if (!address || typeof address === 'string') {
692
+ throw new Error('Failed to start test server');
693
+ }
694
+ const previousCwd = process.cwd();
695
+ let server;
696
+ try {
697
+ process.chdir(contextWorktreePath);
698
+ server = await createMcpServer(`http://127.0.0.1:${address.port}`);
699
+ }
700
+ finally {
701
+ process.chdir(previousCwd);
702
+ }
703
+ const client = new Client({ name: 'team-room-context-test-client', version: '0.1.0' });
704
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
705
+ await server.connect(serverTransport);
706
+ await client.connect(clientTransport);
707
+ const result = await client.callTool({
708
+ name: 'post_room_message',
709
+ arguments: {
710
+ content: 'Implemented through context identity',
711
+ mentions: [{ memberId: members[1].id }],
712
+ },
713
+ });
714
+ await client.close();
715
+ await server.close();
716
+ expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
717
+ const resultContent = result.content;
718
+ const messageText = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
719
+ const message = JSON.parse(messageText);
720
+ expect(message).toMatchObject({
721
+ senderType: 'agent',
722
+ senderInvocationId: invocation.id,
723
+ });
724
+ await expect(prisma.roomMessage.findUnique({ where: { id: message.id } })).resolves.toMatchObject({
725
+ senderType: 'agent',
726
+ senderId: members[0].id,
727
+ senderInvocationId: invocation.id,
728
+ });
729
+ let createdRequestId = '';
730
+ await vi.waitFor(async () => {
731
+ const createdRequest = await prisma.workRequest.findFirst({
732
+ where: {
733
+ teamRunId: teamRun.id,
734
+ triggerMessageId: message.id,
735
+ targetMemberId: members[1].id,
736
+ },
737
+ });
738
+ expect(createdRequest).toMatchObject({
739
+ requesterType: 'agent',
740
+ requesterMemberId: members[0].id,
741
+ status: 'STARTED',
742
+ });
743
+ createdRequestId = createdRequest.id;
744
+ });
745
+ await vi.waitFor(async () => {
746
+ await expect(prisma.agentInvocation.findFirst({
747
+ where: { workRequestId: createdRequestId },
748
+ })).resolves.toMatchObject({
749
+ memberId: members[1].id,
750
+ sessionId: null,
751
+ status: 'FAILED',
752
+ });
753
+ });
754
+ }
755
+ finally {
756
+ if (previousTeamRunId === undefined) {
757
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
758
+ }
759
+ else {
760
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
761
+ }
762
+ if (previousMemberId === undefined) {
763
+ delete process.env.AGENT_TOWER_MEMBER_ID;
764
+ }
765
+ else {
766
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
767
+ }
768
+ if (previousInvocationId === undefined) {
769
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
770
+ }
771
+ else {
772
+ process.env.AGENT_TOWER_INVOCATION_ID = previousInvocationId;
773
+ }
774
+ if (previousSessionId === undefined) {
775
+ delete process.env.AGENT_TOWER_SESSION_ID;
776
+ }
777
+ else {
778
+ process.env.AGENT_TOWER_SESSION_ID = previousSessionId;
779
+ }
780
+ await app.close();
781
+ }
782
+ });
783
+ it('lists TeamRun members through MCP without exposing role prompts', async () => {
784
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
785
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
786
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 2 });
787
+ const runningRequest = await createWorkRequest({
788
+ teamRunId: teamRun.id,
789
+ targetMemberId: members[0].id,
790
+ status: 'STARTED',
791
+ });
792
+ await createRunningInvocation({
793
+ teamRunId: teamRun.id,
794
+ workRequestId: runningRequest.id,
795
+ memberId: members[0].id,
796
+ workspaceId: workspace.id,
797
+ status: 'RUNNING',
798
+ });
799
+ const app = Fastify({ logger: false });
800
+ try {
801
+ process.env.AGENT_TOWER_TEAM_RUN_ID = teamRun.id;
802
+ process.env.AGENT_TOWER_MEMBER_ID = members[0].id;
803
+ await app.register(teamRunRoutes, { prefix: '/api' });
804
+ await app.listen({ port: 0, host: '127.0.0.1' });
805
+ const address = app.server.address();
806
+ if (!address || typeof address === 'string') {
807
+ throw new Error('Failed to start test server');
808
+ }
809
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
810
+ const client = new Client({ name: 'team-member-test-client', version: '0.1.0' });
811
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
812
+ await server.connect(serverTransport);
813
+ await client.connect(clientTransport);
814
+ const result = await client.callTool({
815
+ name: 'list_team_members',
816
+ arguments: {},
817
+ });
818
+ await client.close();
819
+ await server.close();
820
+ expect(result.isError).not.toBe(true);
821
+ const resultContent = result.content;
822
+ const text = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
823
+ const payload = JSON.parse(text);
824
+ expect(payload).toMatchObject({
825
+ teamRunId: teamRun.id,
826
+ currentMemberId: members[0].id,
827
+ });
828
+ expect(payload.members).toHaveLength(2);
829
+ expect(payload.members[0]).toMatchObject({
830
+ id: members[0].id,
831
+ name: 'Member 1',
832
+ aliases: ['member-1'],
833
+ status: 'RUNNING',
834
+ workspacePolicy: 'shared',
835
+ triggerPolicy: 'MENTION_ONLY',
836
+ sessionPolicy: 'new_per_request',
837
+ providerId: 'provider-1',
838
+ });
839
+ expect(payload.members[0]?.capabilities).toMatchObject({
840
+ writeFiles: true,
841
+ runCommands: false,
842
+ mentionMembers: true,
843
+ });
844
+ expect(payload.members[0]).not.toHaveProperty('rolePrompt');
845
+ expect(payload.members[0]).not.toHaveProperty('avatar');
846
+ expect(payload.members[0]).not.toHaveProperty('createdAt');
847
+ expect(payload.members[0]).not.toHaveProperty('updatedAt');
848
+ expect(payload.members[0]).not.toHaveProperty('presetId');
849
+ }
850
+ finally {
851
+ if (previousTeamRunId === undefined) {
852
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
853
+ }
854
+ else {
855
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
856
+ }
857
+ if (previousMemberId === undefined) {
858
+ delete process.env.AGENT_TOWER_MEMBER_ID;
859
+ }
860
+ else {
861
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
862
+ }
863
+ await app.close();
864
+ }
865
+ });
866
+ it('rejects explicit mismatched team_run_id in bound MCP TeamRun tools', async () => {
867
+ const previousEnv = captureTeamRunEnv();
868
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 1 });
869
+ const other = await createFixture({ memberCount: 1 });
870
+ const request = await createWorkRequest({
871
+ teamRunId: teamRun.id,
872
+ targetMemberId: members[0].id,
873
+ status: 'STARTED',
874
+ });
875
+ const invocation = await createRunningInvocation({
876
+ teamRunId: teamRun.id,
877
+ workRequestId: request.id,
878
+ memberId: members[0].id,
879
+ workspaceId: workspace.id,
880
+ });
881
+ const routeScheduler = createRouteSchedulerMock();
882
+ const app = Fastify({ logger: false });
883
+ try {
884
+ setTeamRunEnv({
885
+ AGENT_TOWER_TEAM_RUN_ID: teamRun.id,
886
+ AGENT_TOWER_MEMBER_ID: members[0].id,
887
+ AGENT_TOWER_INVOCATION_ID: invocation.id,
888
+ AGENT_TOWER_SESSION_ID: undefined,
889
+ });
890
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
891
+ await app.listen({ port: 0, host: '127.0.0.1' });
892
+ const address = app.server.address();
893
+ if (!address || typeof address === 'string') {
894
+ throw new Error('Failed to start test server');
895
+ }
896
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
897
+ const client = new Client({ name: 'team-run-mismatch-test-client', version: '0.1.0' });
898
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
899
+ await server.connect(serverTransport);
900
+ await client.connect(clientTransport);
901
+ try {
902
+ const cases = [
903
+ {
904
+ name: 'post_room_message',
905
+ arguments: {
906
+ team_run_id: other.teamRun.id,
907
+ content: 'This should be rejected before reaching the API',
908
+ },
909
+ },
910
+ {
911
+ name: 'list_room_messages',
912
+ arguments: {
913
+ team_run_id: other.teamRun.id,
914
+ },
915
+ },
916
+ {
917
+ name: 'list_team_members',
918
+ arguments: {
919
+ team_run_id: other.teamRun.id,
920
+ },
921
+ },
922
+ {
923
+ name: 'stop_member_work',
924
+ arguments: {
925
+ team_run_id: other.teamRun.id,
926
+ member_id: other.members[0].id,
927
+ cancel_queued: true,
928
+ },
929
+ },
930
+ ];
931
+ for (const toolCall of cases) {
932
+ const result = await client.callTool(toolCall);
933
+ expect(result.isError, toolCall.name).toBe(true);
934
+ expect(getMcpToolText(result), toolCall.name).toContain(TEAM_RUN_MISMATCH_ERROR);
935
+ }
936
+ }
937
+ finally {
938
+ await client.close();
939
+ await server.close();
940
+ }
941
+ await expect(prisma.roomMessage.count()).resolves.toBe(0);
942
+ expect(routeScheduler.stopMemberWork).not.toHaveBeenCalled();
943
+ }
944
+ finally {
945
+ restoreTeamRunEnv(previousEnv);
946
+ await app.close();
947
+ }
948
+ });
949
+ it('posts MCP room messages as user when bound TeamRun identity is partial', async () => {
950
+ const previousEnv = captureTeamRunEnv();
951
+ const { teamRun, members } = await createFixture({ memberCount: 2 });
952
+ const app = Fastify({ logger: false });
953
+ try {
954
+ setTeamRunEnv({
955
+ AGENT_TOWER_TEAM_RUN_ID: teamRun.id,
956
+ AGENT_TOWER_MEMBER_ID: members[0].id,
957
+ AGENT_TOWER_INVOCATION_ID: undefined,
958
+ AGENT_TOWER_SESSION_ID: undefined,
959
+ });
960
+ await app.register(teamRunRoutes, { prefix: '/api' });
961
+ await app.listen({ port: 0, host: '127.0.0.1' });
962
+ const address = app.server.address();
963
+ if (!address || typeof address === 'string') {
964
+ throw new Error('Failed to start test server');
965
+ }
966
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
967
+ const client = new Client({ name: 'team-run-partial-identity-test-client', version: '0.1.0' });
968
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
969
+ await server.connect(serverTransport);
970
+ await client.connect(clientTransport);
971
+ let messageId;
972
+ try {
973
+ const result = await client.callTool({
974
+ name: 'post_room_message',
975
+ arguments: {
976
+ content: 'Partial identity should not be sent as agent',
977
+ },
978
+ });
979
+ expect(result.isError, getMcpToolText(result)).not.toBe(true);
980
+ const message = JSON.parse(getMcpToolText(result));
981
+ messageId = message.id;
982
+ expect(message).toMatchObject({
983
+ senderType: 'user',
984
+ senderId: null,
985
+ senderInvocationId: null,
986
+ });
987
+ }
988
+ finally {
989
+ await client.close();
990
+ await server.close();
991
+ }
992
+ await expect(prisma.roomMessage.findUnique({ where: { id: messageId } })).resolves.toMatchObject({
993
+ senderType: 'user',
994
+ senderId: null,
995
+ senderInvocationId: null,
996
+ });
997
+ }
998
+ finally {
999
+ restoreTeamRunEnv(previousEnv);
1000
+ await app.close();
1001
+ }
1002
+ });
1003
+ it('auto-starts USER_MESSAGES work when a user posts an unmentioned room message', async () => {
1004
+ const { teamRun, members } = await createFixture({
1005
+ memberCount: 2,
1006
+ teamRunMode: 'AUTO',
1007
+ triggerPolicies: ['USER_MESSAGES', 'MENTION_ONLY'],
1008
+ });
1009
+ const routeScheduler = createRouteSchedulerMock();
1010
+ const startedTeamRunIds = [];
1011
+ routeScheduler.startNextSessions = vi.fn(async (teamRunId) => {
1012
+ startedTeamRunIds.push(teamRunId);
1013
+ return await new Promise(() => { });
1014
+ });
1015
+ const app = Fastify({ logger: false });
1016
+ try {
1017
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1018
+ const response = await app.inject({
1019
+ method: 'POST',
1020
+ url: `/api/team-runs/${teamRun.id}/messages`,
1021
+ payload: {
1022
+ content: '普通用户消息',
1023
+ senderType: 'user',
1024
+ },
1025
+ });
1026
+ expect(response.statusCode).toBe(201);
1027
+ const message = response.json();
1028
+ expect(message.mentions).toEqual([]);
1029
+ expect(message.workRequestIds).toHaveLength(1);
1030
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1031
+ await expect(prisma.workRequest.findUnique({ where: { id: message.workRequestIds[0] } })).resolves.toMatchObject({
1032
+ targetMemberId: members[0].id,
1033
+ instruction: '普通用户消息',
1034
+ status: 'QUEUED',
1035
+ });
1036
+ await expect(prisma.agentInvocation.count({
1037
+ where: {
1038
+ teamRunId: teamRun.id,
1039
+ memberId: members[0].id,
1040
+ },
1041
+ })).resolves.toBe(0);
1042
+ await vi.waitFor(() => {
1043
+ expect(routeScheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1044
+ expect(startedTeamRunIds).toEqual([teamRun.id]);
1045
+ });
1046
+ }
1047
+ finally {
1048
+ await app.close();
1049
+ }
1050
+ });
1051
+ it('creates an initial Team Room message without WorkRequests for unmentioned MENTION_ONLY members', async () => {
1052
+ const project = await prisma.project.create({
1053
+ data: {
1054
+ name: 'Initial message project',
1055
+ repoPath: testDir,
1056
+ },
1057
+ });
1058
+ const task = await prisma.task.create({
1059
+ data: {
1060
+ title: 'Build CSV import',
1061
+ description: 'Parse uploaded files and show validation errors.',
1062
+ projectId: project.id,
1063
+ },
1064
+ });
1065
+ const implementerPreset = await createMemberPreset({ name: 'Implementer', triggerPolicy: 'MENTION_ONLY' });
1066
+ const reviewerPreset = await createMemberPreset({ name: 'Reviewer', triggerPolicy: 'MENTION_ONLY' });
1067
+ const routeScheduler = createRouteSchedulerMock();
1068
+ const app = Fastify({ logger: false });
1069
+ try {
1070
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1071
+ const response = await app.inject({
1072
+ method: 'POST',
1073
+ url: `/api/tasks/${task.id}/team-runs`,
1074
+ payload: {
1075
+ mode: 'AUTO',
1076
+ memberPresetIds: [implementerPreset.id, reviewerPreset.id],
1077
+ },
1078
+ });
1079
+ expect(response.statusCode).toBe(201);
1080
+ const teamRun = response.json();
1081
+ const initialContent = 'Build CSV import\n\nParse uploaded files and show validation errors.';
1082
+ expect(teamRun.messages).toHaveLength(1);
1083
+ expect(teamRun.messages[0]).toMatchObject({
1084
+ senderType: 'user',
1085
+ kind: 'chat',
1086
+ content: initialContent,
1087
+ mentions: [],
1088
+ workRequestIds: [],
1089
+ });
1090
+ expect(teamRun.workRequests).toEqual([]);
1091
+ const messagesResponse = await app.inject({
1092
+ method: 'GET',
1093
+ url: `/api/team-runs/${teamRun.id}/messages`,
1094
+ });
1095
+ expect(messagesResponse.statusCode).toBe(200);
1096
+ const messages = messagesResponse.json();
1097
+ expect(messages).toEqual([
1098
+ expect.objectContaining({
1099
+ id: teamRun.messages[0].id,
1100
+ content: initialContent,
1101
+ mentions: [],
1102
+ workRequestIds: [],
1103
+ }),
1104
+ ]);
1105
+ const workRequestsResponse = await app.inject({
1106
+ method: 'GET',
1107
+ url: `/api/team-runs/${teamRun.id}/work-requests`,
1108
+ });
1109
+ expect(workRequestsResponse.statusCode).toBe(200);
1110
+ const workRequests = workRequestsResponse.json();
1111
+ expect(workRequests).toEqual([]);
1112
+ await expect(prisma.roomMessage.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(1);
1113
+ await expect(prisma.workRequest.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
1114
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1115
+ await expect(prisma.agentInvocation.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
1116
+ }
1117
+ finally {
1118
+ await app.close();
1119
+ }
1120
+ });
1121
+ it('creates USER_MESSAGES WorkRequests from a title-only initial message and auto-starts it', async () => {
1122
+ const project = await prisma.project.create({
1123
+ data: {
1124
+ name: 'Title-only project',
1125
+ repoPath: testDir,
1126
+ },
1127
+ });
1128
+ const task = await prisma.task.create({
1129
+ data: {
1130
+ title: 'Investigate slow dashboard',
1131
+ projectId: project.id,
1132
+ },
1133
+ });
1134
+ const preset = await createMemberPreset({ name: 'Leader', triggerPolicy: 'USER_MESSAGES' });
1135
+ const routeScheduler = createRouteSchedulerMock();
1136
+ const app = Fastify({ logger: false });
1137
+ try {
1138
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1139
+ const response = await app.inject({
1140
+ method: 'POST',
1141
+ url: `/api/tasks/${task.id}/team-runs`,
1142
+ payload: {
1143
+ mode: 'AUTO',
1144
+ memberPresetIds: [preset.id],
1145
+ },
1146
+ });
1147
+ expect(response.statusCode).toBe(201);
1148
+ const teamRun = response.json();
1149
+ expect(teamRun.messages).toHaveLength(1);
1150
+ expect(teamRun.messages[0]).toMatchObject({
1151
+ content: 'Investigate slow dashboard',
1152
+ workRequestIds: [teamRun.workRequests[0].id],
1153
+ });
1154
+ expect(teamRun.workRequests[0]).toMatchObject({
1155
+ instruction: 'Investigate slow dashboard',
1156
+ status: 'QUEUED',
1157
+ });
1158
+ await vi.waitFor(() => {
1159
+ expect(routeScheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1160
+ });
1161
+ }
1162
+ finally {
1163
+ await app.close();
1164
+ }
1165
+ });
1166
+ it('creates an initial WorkRequest only for a mentioned MENTION_ONLY member', async () => {
1167
+ const project = await prisma.project.create({
1168
+ data: {
1169
+ name: 'Mentioned initial message project',
1170
+ repoPath: testDir,
1171
+ },
1172
+ });
1173
+ const task = await prisma.task.create({
1174
+ data: {
1175
+ title: 'Investigate import failures',
1176
+ description: 'Please @Reviewer check the error handling.',
1177
+ projectId: project.id,
1178
+ },
1179
+ });
1180
+ const implementerPreset = await createMemberPreset({ name: 'Implementer', triggerPolicy: 'MENTION_ONLY' });
1181
+ const reviewerPreset = await createMemberPreset({ name: 'Reviewer', triggerPolicy: 'MENTION_ONLY' });
1182
+ const routeScheduler = createRouteSchedulerMock();
1183
+ const app = Fastify({ logger: false });
1184
+ try {
1185
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1186
+ const response = await app.inject({
1187
+ method: 'POST',
1188
+ url: `/api/tasks/${task.id}/team-runs`,
1189
+ payload: {
1190
+ mode: 'AUTO',
1191
+ memberPresetIds: [implementerPreset.id, reviewerPreset.id],
1192
+ },
1193
+ });
1194
+ expect(response.statusCode).toBe(201);
1195
+ const teamRun = response.json();
1196
+ const reviewer = teamRun.members.find((member) => member.name === 'Reviewer');
1197
+ const implementer = teamRun.members.find((member) => member.name === 'Implementer');
1198
+ const initialContent = 'Investigate import failures\n\nPlease @Reviewer check the error handling.';
1199
+ expect(reviewer).toBeDefined();
1200
+ expect(implementer).toBeDefined();
1201
+ expect(teamRun.messages).toHaveLength(1);
1202
+ expect(teamRun.messages[0]).toMatchObject({
1203
+ content: initialContent,
1204
+ mentions: [{ memberId: reviewer.id, label: 'Reviewer' }],
1205
+ });
1206
+ expect(teamRun.workRequests).toEqual([
1207
+ expect.objectContaining({
1208
+ id: teamRun.messages[0].workRequestIds[0],
1209
+ targetMemberId: reviewer.id,
1210
+ instruction: initialContent,
1211
+ status: 'QUEUED',
1212
+ }),
1213
+ ]);
1214
+ expect(teamRun.workRequests[0].targetMemberId).not.toBe(implementer.id);
1215
+ await vi.waitFor(() => {
1216
+ expect(routeScheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1217
+ });
1218
+ await expect(prisma.agentInvocation.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(1);
1219
+ }
1220
+ finally {
1221
+ await app.close();
1222
+ }
1223
+ });
1224
+ it('matches initial @mention tokens exactly instead of matching name prefixes', async () => {
1225
+ const project = await prisma.project.create({
1226
+ data: {
1227
+ name: 'Mention prefix project',
1228
+ repoPath: testDir,
1229
+ },
1230
+ });
1231
+ const task = await prisma.task.create({
1232
+ data: {
1233
+ title: 'Route QA review',
1234
+ description: 'Please @QA-Lead verify the release notes.',
1235
+ projectId: project.id,
1236
+ },
1237
+ });
1238
+ const qaPreset = await createMemberPreset({ name: 'QA', triggerPolicy: 'MENTION_ONLY' });
1239
+ const qaLeadPreset = await createMemberPreset({ name: 'QA-Lead', triggerPolicy: 'MENTION_ONLY' });
1240
+ const routeScheduler = createRouteSchedulerMock();
1241
+ const app = Fastify({ logger: false });
1242
+ try {
1243
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1244
+ const response = await app.inject({
1245
+ method: 'POST',
1246
+ url: `/api/tasks/${task.id}/team-runs`,
1247
+ payload: {
1248
+ mode: 'AUTO',
1249
+ memberPresetIds: [qaPreset.id, qaLeadPreset.id],
1250
+ },
1251
+ });
1252
+ expect(response.statusCode).toBe(201);
1253
+ const teamRun = response.json();
1254
+ const qa = teamRun.members.find((member) => member.name === 'QA');
1255
+ const qaLead = teamRun.members.find((member) => member.name === 'QA-Lead');
1256
+ expect(qa).toBeDefined();
1257
+ expect(qaLead).toBeDefined();
1258
+ expect(teamRun.messages[0].mentions).toEqual([{ memberId: qaLead.id, label: 'QA-Lead' }]);
1259
+ expect(teamRun.workRequests).toEqual([
1260
+ expect.objectContaining({
1261
+ id: teamRun.messages[0].workRequestIds[0],
1262
+ targetMemberId: qaLead.id,
1263
+ }),
1264
+ ]);
1265
+ expect(teamRun.workRequests[0].targetMemberId).not.toBe(qa.id);
1266
+ }
1267
+ finally {
1268
+ await app.close();
1269
+ }
1270
+ });
1271
+ it('does not create initial WorkRequests for ambiguous @mention aliases', async () => {
1272
+ const project = await prisma.project.create({
1273
+ data: {
1274
+ name: 'Ambiguous mention project',
1275
+ repoPath: testDir,
1276
+ },
1277
+ });
1278
+ const task = await prisma.task.create({
1279
+ data: {
1280
+ title: 'Review ambiguous owner',
1281
+ description: 'Please @reviewer check this.',
1282
+ projectId: project.id,
1283
+ },
1284
+ });
1285
+ const firstPreset = await createMemberPreset({ name: 'Frontend Reviewer', triggerPolicy: 'MENTION_ONLY' });
1286
+ const secondPreset = await createMemberPreset({ name: 'Backend Reviewer', triggerPolicy: 'MENTION_ONLY' });
1287
+ await prisma.memberPreset.updateMany({
1288
+ where: { id: { in: [firstPreset.id, secondPreset.id] } },
1289
+ data: { aliases: stringifyJson(['reviewer']) },
1290
+ });
1291
+ const routeScheduler = createRouteSchedulerMock();
1292
+ const app = Fastify({ logger: false });
1293
+ try {
1294
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1295
+ const response = await app.inject({
1296
+ method: 'POST',
1297
+ url: `/api/tasks/${task.id}/team-runs`,
1298
+ payload: {
1299
+ mode: 'AUTO',
1300
+ memberPresetIds: [firstPreset.id, secondPreset.id],
1301
+ },
1302
+ });
1303
+ expect(response.statusCode).toBe(201);
1304
+ const teamRun = response.json();
1305
+ expect(teamRun.messages[0]).toMatchObject({
1306
+ mentions: [],
1307
+ workRequestIds: [],
1308
+ });
1309
+ expect(teamRun.workRequests).toEqual([]);
1310
+ await expect(prisma.workRequest.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
1311
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1312
+ }
1313
+ finally {
1314
+ await app.close();
1315
+ }
1316
+ });
1317
+ it('does not fall back to USER_MESSAGES when initial @mention aliases are ambiguous', async () => {
1318
+ const project = await prisma.project.create({
1319
+ data: {
1320
+ name: 'Mixed ambiguous mention project',
1321
+ repoPath: testDir,
1322
+ },
1323
+ });
1324
+ const task = await prisma.task.create({
1325
+ data: {
1326
+ title: 'Route ambiguous review',
1327
+ description: 'Please @reviewer decide who owns this.',
1328
+ projectId: project.id,
1329
+ },
1330
+ });
1331
+ const firstPreset = await createMemberPreset({ name: 'Frontend Reviewer', triggerPolicy: 'MENTION_ONLY' });
1332
+ const secondPreset = await createMemberPreset({ name: 'Backend Reviewer', triggerPolicy: 'MENTION_ONLY' });
1333
+ const observerPreset = await createMemberPreset({ name: 'Observer', triggerPolicy: 'USER_MESSAGES' });
1334
+ await prisma.memberPreset.updateMany({
1335
+ where: { id: { in: [firstPreset.id, secondPreset.id] } },
1336
+ data: { aliases: stringifyJson(['reviewer']) },
1337
+ });
1338
+ const routeScheduler = createRouteSchedulerMock();
1339
+ const app = Fastify({ logger: false });
1340
+ try {
1341
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1342
+ const response = await app.inject({
1343
+ method: 'POST',
1344
+ url: `/api/tasks/${task.id}/team-runs`,
1345
+ payload: {
1346
+ mode: 'AUTO',
1347
+ memberPresetIds: [firstPreset.id, secondPreset.id, observerPreset.id],
1348
+ },
1349
+ });
1350
+ expect(response.statusCode).toBe(201);
1351
+ const teamRun = response.json();
1352
+ expect(teamRun.messages[0]).toMatchObject({
1353
+ mentions: [],
1354
+ workRequestIds: [],
1355
+ });
1356
+ expect(teamRun.workRequests).toEqual([]);
1357
+ await expect(prisma.workRequest.count({ where: { teamRunId: teamRun.id } })).resolves.toBe(0);
1358
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1359
+ }
1360
+ finally {
1361
+ await app.close();
1362
+ }
1363
+ });
1364
+ it('rejects creating a TeamRun for a blank-title task before writing TeamRun data', async () => {
1365
+ const project = await prisma.project.create({
1366
+ data: {
1367
+ name: 'Blank title project',
1368
+ repoPath: testDir,
1369
+ },
1370
+ });
1371
+ const task = await prisma.task.create({
1372
+ data: {
1373
+ title: ' ',
1374
+ projectId: project.id,
1375
+ },
1376
+ });
1377
+ const preset = await createMemberPreset({ name: 'Leader', triggerPolicy: 'USER_MESSAGES' });
1378
+ const routeScheduler = createRouteSchedulerMock();
1379
+ const app = Fastify({ logger: false });
1380
+ try {
1381
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1382
+ const response = await app.inject({
1383
+ method: 'POST',
1384
+ url: `/api/tasks/${task.id}/team-runs`,
1385
+ payload: {
1386
+ mode: 'AUTO',
1387
+ memberPresetIds: [preset.id],
1388
+ },
1389
+ });
1390
+ expect(response.statusCode).toBe(400);
1391
+ expect(response.json()).toMatchObject({ code: 'VALIDATION_ERROR' });
1392
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1393
+ await expect(prisma.teamRun.count({ where: { taskId: task.id } })).resolves.toBe(0);
1394
+ await expect(prisma.roomMessage.count()).resolves.toBe(0);
1395
+ await expect(prisma.workRequest.count()).resolves.toBe(0);
1396
+ }
1397
+ finally {
1398
+ await app.close();
1399
+ }
1400
+ });
1401
+ it('rolls back TeamRun creation when initial WorkRequest creation fails and allows retry', async () => {
1402
+ const project = await prisma.project.create({
1403
+ data: {
1404
+ name: 'Atomic TeamRun project',
1405
+ repoPath: testDir,
1406
+ },
1407
+ });
1408
+ const task = await prisma.task.create({
1409
+ data: {
1410
+ title: 'Start atomically',
1411
+ projectId: project.id,
1412
+ },
1413
+ });
1414
+ const preset = await createMemberPreset({ name: 'Implementer', triggerPolicy: 'USER_MESSAGES' });
1415
+ const routeScheduler = createRouteSchedulerMock();
1416
+ const originalTransaction = prisma.$transaction.bind(prisma);
1417
+ const transactionSpy = vi.spyOn(prisma, '$transaction');
1418
+ transactionSpy.mockImplementationOnce(async (arg, ...rest) => {
1419
+ if (typeof arg !== 'function') {
1420
+ return originalTransaction(arg, ...rest);
1421
+ }
1422
+ return originalTransaction(async (tx) => {
1423
+ const failingTx = new Proxy(tx, {
1424
+ get(target, property, receiver) {
1425
+ if (property !== 'workRequest') {
1426
+ return Reflect.get(target, property, receiver);
1427
+ }
1428
+ const delegate = Reflect.get(target, property, receiver);
1429
+ return new Proxy(delegate, {
1430
+ get(delegateTarget, delegateProperty, delegateReceiver) {
1431
+ if (delegateProperty === 'create') {
1432
+ return async () => {
1433
+ throw new Error('injected initial WorkRequest failure');
1434
+ };
1435
+ }
1436
+ return Reflect.get(delegateTarget, delegateProperty, delegateReceiver);
1437
+ },
1438
+ });
1439
+ },
1440
+ });
1441
+ return arg(failingTx);
1442
+ }, ...rest);
1443
+ });
1444
+ const app = Fastify({ logger: false });
1445
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1446
+ try {
1447
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
1448
+ const failedResponse = await app.inject({
1449
+ method: 'POST',
1450
+ url: `/api/tasks/${task.id}/team-runs`,
1451
+ payload: {
1452
+ mode: 'AUTO',
1453
+ memberPresetIds: [preset.id],
1454
+ },
1455
+ });
1456
+ expect(failedResponse.statusCode).toBe(500);
1457
+ expect(routeScheduler.startNextSessions).not.toHaveBeenCalled();
1458
+ await expect(prisma.teamRun.count({ where: { taskId: task.id } })).resolves.toBe(0);
1459
+ await expect(prisma.teamMember.count()).resolves.toBe(0);
1460
+ await expect(prisma.roomMessage.count()).resolves.toBe(0);
1461
+ await expect(prisma.workRequest.count()).resolves.toBe(0);
1462
+ const retryResponse = await app.inject({
1463
+ method: 'POST',
1464
+ url: `/api/tasks/${task.id}/team-runs`,
1465
+ payload: {
1466
+ mode: 'AUTO',
1467
+ memberPresetIds: [preset.id],
1468
+ },
1469
+ });
1470
+ expect(retryResponse.statusCode).toBe(201);
1471
+ const teamRun = retryResponse.json();
1472
+ expect(teamRun.messages[0].workRequestIds).toEqual([teamRun.workRequests[0].id]);
1473
+ expect(teamRun.workRequests[0]).toMatchObject({ status: 'QUEUED' });
1474
+ await vi.waitFor(() => {
1475
+ expect(routeScheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1476
+ });
1477
+ }
1478
+ finally {
1479
+ consoleErrorSpy.mockRestore();
1480
+ await app.close();
1481
+ }
1482
+ });
1483
+ it('SessionManager.stop routes TeamRun sessions through the reconciler', async () => {
1484
+ const { workspace, teamRun, members } = await createFixture();
1485
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
1486
+ const session = await prisma.session.create({
1487
+ data: {
1488
+ workspaceId: workspace.id,
1489
+ agentType: 'CODEX',
1490
+ providerId: 'provider-1',
1491
+ prompt: 'Do the work',
1492
+ status: 'RUNNING',
1493
+ },
1494
+ });
1495
+ const invocation = await createRunningInvocation({
1496
+ teamRunId: teamRun.id,
1497
+ workRequestId: request.id,
1498
+ memberId: members[0].id,
1499
+ workspaceId: workspace.id,
1500
+ sessionId: session.id,
1501
+ status: 'RUNNING',
1502
+ });
1503
+ const manager = new SessionManager(new EventBus(), service);
1504
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
1505
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
1506
+ status: 'CANCELLED',
1507
+ });
1508
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
1509
+ status: 'CANCELLED',
1510
+ nextRoomReplyReminderAt: null,
1511
+ });
1512
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
1513
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1514
+ });
1515
+ it('SessionManager.stop leaves solo sessions without TeamRun reconciliation', async () => {
1516
+ const { workspace } = await createFixture();
1517
+ const session = await prisma.session.create({
1518
+ data: {
1519
+ workspaceId: workspace.id,
1520
+ agentType: 'CODEX',
1521
+ providerId: 'provider-1',
1522
+ prompt: 'Solo work',
1523
+ status: 'RUNNING',
1524
+ },
1525
+ });
1526
+ const manager = new SessionManager(new EventBus(), service);
1527
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
1528
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
1529
+ status: 'CANCELLED',
1530
+ });
1531
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
1532
+ expect(scheduler.startNextSessions).not.toHaveBeenCalled();
1533
+ await expect(prisma.agentInvocation.count()).resolves.toBe(0);
1534
+ });
1535
+ });
1536
+ //# sourceMappingURL=team-reconciler.service.test.js.map