agent-tower 0.4.15 → 0.4.16-beta.0

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 (141) hide show
  1. package/dist/core/event-bus.d.ts +2 -0
  2. package/dist/core/event-bus.d.ts.map +1 -1
  3. package/dist/core/event-bus.js.map +1 -1
  4. package/dist/executors/__tests__/codex.executor.test.d.ts +2 -0
  5. package/dist/executors/__tests__/codex.executor.test.d.ts.map +1 -0
  6. package/dist/executors/__tests__/codex.executor.test.js +28 -0
  7. package/dist/executors/__tests__/codex.executor.test.js.map +1 -0
  8. package/dist/executors/codex.executor.d.ts +1 -0
  9. package/dist/executors/codex.executor.d.ts.map +1 -1
  10. package/dist/executors/codex.executor.js +19 -1
  11. package/dist/executors/codex.executor.js.map +1 -1
  12. package/dist/mcp/context.d.ts +3 -0
  13. package/dist/mcp/context.d.ts.map +1 -1
  14. package/dist/mcp/context.js +10 -1
  15. package/dist/mcp/context.js.map +1 -1
  16. package/dist/mcp/http-client.d.ts +24 -1
  17. package/dist/mcp/http-client.d.ts.map +1 -1
  18. package/dist/mcp/http-client.js +29 -2
  19. package/dist/mcp/http-client.js.map +1 -1
  20. package/dist/mcp/server.d.ts.map +1 -1
  21. package/dist/mcp/server.js +190 -0
  22. package/dist/mcp/server.js.map +1 -1
  23. package/dist/routes/index.d.ts.map +1 -1
  24. package/dist/routes/index.js +3 -0
  25. package/dist/routes/index.js.map +1 -1
  26. package/dist/routes/system.d.ts.map +1 -1
  27. package/dist/routes/system.js +35 -1
  28. package/dist/routes/system.js.map +1 -1
  29. package/dist/routes/team-runs.d.ts +11 -0
  30. package/dist/routes/team-runs.d.ts.map +1 -0
  31. package/dist/routes/team-runs.js +299 -0
  32. package/dist/routes/team-runs.js.map +1 -0
  33. package/dist/services/__tests__/session-manager.team-run.test.d.ts +2 -0
  34. package/dist/services/__tests__/session-manager.team-run.test.d.ts.map +1 -0
  35. package/dist/services/__tests__/session-manager.team-run.test.js +286 -0
  36. package/dist/services/__tests__/session-manager.team-run.test.js.map +1 -0
  37. package/dist/services/__tests__/team-lock.service.test.d.ts +2 -0
  38. package/dist/services/__tests__/team-lock.service.test.d.ts.map +1 -0
  39. package/dist/services/__tests__/team-lock.service.test.js +81 -0
  40. package/dist/services/__tests__/team-lock.service.test.js.map +1 -0
  41. package/dist/services/__tests__/team-reconciler.service.test.d.ts +2 -0
  42. package/dist/services/__tests__/team-reconciler.service.test.d.ts.map +1 -0
  43. package/dist/services/__tests__/team-reconciler.service.test.js +1038 -0
  44. package/dist/services/__tests__/team-reconciler.service.test.js.map +1 -0
  45. package/dist/services/__tests__/team-run.service.test.d.ts +2 -0
  46. package/dist/services/__tests__/team-run.service.test.d.ts.map +1 -0
  47. package/dist/services/__tests__/team-run.service.test.js +447 -0
  48. package/dist/services/__tests__/team-run.service.test.js.map +1 -0
  49. package/dist/services/__tests__/team-scheduler.service.test.d.ts +2 -0
  50. package/dist/services/__tests__/team-scheduler.service.test.d.ts.map +1 -0
  51. package/dist/services/__tests__/team-scheduler.service.test.js +1158 -0
  52. package/dist/services/__tests__/team-scheduler.service.test.js.map +1 -0
  53. package/dist/services/session-manager.d.ts +31 -1
  54. package/dist/services/session-manager.d.ts.map +1 -1
  55. package/dist/services/session-manager.js +110 -2
  56. package/dist/services/session-manager.js.map +1 -1
  57. package/dist/services/team-lock.service.d.ts +22 -0
  58. package/dist/services/team-lock.service.d.ts.map +1 -0
  59. package/dist/services/team-lock.service.js +45 -0
  60. package/dist/services/team-lock.service.js.map +1 -0
  61. package/dist/services/team-reconciler.service.d.ts +44 -0
  62. package/dist/services/team-reconciler.service.d.ts.map +1 -0
  63. package/dist/services/team-reconciler.service.js +286 -0
  64. package/dist/services/team-reconciler.service.js.map +1 -0
  65. package/dist/services/team-run-events.d.ts +13 -0
  66. package/dist/services/team-run-events.d.ts.map +1 -0
  67. package/dist/services/team-run-events.js +27 -0
  68. package/dist/services/team-run-events.js.map +1 -0
  69. package/dist/services/team-run.service.d.ts +89 -0
  70. package/dist/services/team-run.service.d.ts.map +1 -0
  71. package/dist/services/team-run.service.js +577 -0
  72. package/dist/services/team-run.service.js.map +1 -0
  73. package/dist/services/team-scheduler.service.d.ts +89 -0
  74. package/dist/services/team-scheduler.service.d.ts.map +1 -0
  75. package/dist/services/team-scheduler.service.js +750 -0
  76. package/dist/services/team-scheduler.service.js.map +1 -0
  77. package/dist/socket/events.d.ts +1 -1
  78. package/dist/socket/events.d.ts.map +1 -1
  79. package/dist/socket/events.js.map +1 -1
  80. package/dist/socket/socket-gateway.d.ts.map +1 -1
  81. package/dist/socket/socket-gateway.js +5 -1
  82. package/dist/socket/socket-gateway.js.map +1 -1
  83. package/dist/web/assets/AgentDemoPage-p9YI4_l4.js +1 -0
  84. package/dist/web/assets/{DemoPage-XwuS8vNB.js → DemoPage-B5DTSEbS.js} +3 -3
  85. package/dist/web/assets/{GeneralSettingsPage-CliIgpwf.js → GeneralSettingsPage-Cspr7Vol.js} +1 -1
  86. package/dist/web/assets/{NotificationSettingsPage-y3vhVgPv.js → NotificationSettingsPage-C9VfrRr-.js} +1 -1
  87. package/dist/web/assets/{ProfileSettingsPage-CkU_kZKG.js → ProfileSettingsPage-CNugU40a.js} +1 -1
  88. package/dist/web/assets/ProjectKanbanPage-CkGNuqxq.js +87 -0
  89. package/dist/web/assets/{ProjectSettingsPage-B6xhbziO.js → ProjectSettingsPage-f1dg0XMf.js} +1 -1
  90. package/dist/web/assets/{ProviderSettingsPage-CfvdeoEU.js → ProviderSettingsPage-D_KWkgRM.js} +1 -1
  91. package/dist/web/assets/TeamSettingsPage-B6WciZyi.js +1 -0
  92. package/dist/web/assets/{button-BWFTEdOr.js → button-B6JaSbDB.js} +1 -1
  93. package/dist/web/assets/{chevron-down-CuPdBAx-.js → chevron-down-CACy4UFq.js} +1 -1
  94. package/dist/web/assets/{chevron-right-Cs8vYTMn.js → chevron-right-DFWfnDJY.js} +1 -1
  95. package/dist/web/assets/chevron-up-CGlf6jzw.js +1 -0
  96. package/dist/web/assets/{circle-alert-EUyZcWhp.js → circle-alert-BSAUEd9O.js} +1 -1
  97. package/dist/web/assets/{circle-check-BXZTzqw0.js → circle-check-DMK8auwb.js} +1 -1
  98. package/dist/web/assets/{code-block-OCS4YCEC-BxUpvXK_.js → code-block-OCS4YCEC-Hn75KHRK.js} +1 -1
  99. package/dist/web/assets/{confirm-dialog-CDLHRthd.js → confirm-dialog-DHI2f7Ni.js} +1 -1
  100. package/dist/web/assets/{folder-picker-CUbhsnhi.js → folder-picker-CtQkbWfa.js} +1 -1
  101. package/dist/web/assets/index-BFAA3PTl.js +13 -0
  102. package/dist/web/assets/index-mBCb67dB.css +1 -0
  103. package/dist/web/assets/{loader-circle-BHzDVpxt.js → loader-circle-CkDnf8ST.js} +1 -1
  104. package/dist/web/assets/{mermaid-NOHMQCX5-BOSwJqP0.js → mermaid-NOHMQCX5-DJFgrXPd.js} +44 -44
  105. package/dist/web/assets/modal-B5IRN7QI.js +1 -0
  106. package/dist/web/assets/{pencil-BMxBxIhw.js → pencil-CJY6Ahn7.js} +1 -1
  107. package/dist/web/assets/{select-BUmRG0LY.js → select-BPZZlla1.js} +1 -1
  108. package/dist/web/assets/{use-profiles-C1vlPE-2.js → use-profiles-C2k04ICZ.js} +1 -1
  109. package/dist/web/assets/{use-projects-Bcd5hIOY.js → use-projects-BxuE-ulT.js} +1 -1
  110. package/dist/web/assets/{use-providers-Cdxr4Jbz.js → use-providers-C7fIDWzP.js} +1 -1
  111. package/dist/web/index.html +2 -2
  112. package/node_modules/@agent-tower/shared/dist/socket/events.d.ts +10 -0
  113. package/node_modules/@agent-tower/shared/dist/socket/events.d.ts.map +1 -1
  114. package/node_modules/@agent-tower/shared/dist/socket/events.js +1 -0
  115. package/node_modules/@agent-tower/shared/dist/socket/events.js.map +1 -1
  116. package/node_modules/@agent-tower/shared/dist/types.d.ts +153 -0
  117. package/node_modules/@agent-tower/shared/dist/types.d.ts.map +1 -1
  118. package/node_modules/@agent-tower/shared/dist/types.js.map +1 -1
  119. package/node_modules/@prisma/client/.prisma/client/default.d.ts +1 -0
  120. package/node_modules/@prisma/client/.prisma/client/default.js +1 -0
  121. package/node_modules/@prisma/client/.prisma/client/edge.d.ts +1 -0
  122. package/node_modules/@prisma/client/.prisma/client/edge.js +392 -0
  123. package/node_modules/@prisma/client/.prisma/client/index-browser.js +381 -0
  124. package/node_modules/@prisma/client/.prisma/client/index.d.ts +25768 -0
  125. package/node_modules/@prisma/client/.prisma/client/index.js +417 -0
  126. package/node_modules/@prisma/client/.prisma/client/libquery_engine-darwin-arm64.dylib.node +0 -0
  127. package/node_modules/@prisma/client/.prisma/client/package.json +97 -0
  128. package/node_modules/@prisma/client/.prisma/client/query_engine-windows.dll.node +0 -0
  129. package/node_modules/@prisma/client/.prisma/client/schema.prisma +280 -0
  130. package/node_modules/@prisma/client/.prisma/client/wasm.d.ts +1 -0
  131. package/node_modules/@prisma/client/.prisma/client/wasm.js +381 -0
  132. package/node_modules/@prisma/client/package.json +3 -2
  133. package/package.json +2 -1
  134. package/prisma/migrations/20260518000000_add_team_run_collaboration/migration.sql +150 -0
  135. package/prisma/migrations/20260522000000_add_team_member_session_policy/migration.sql +2 -0
  136. package/prisma/schema.prisma +131 -1
  137. package/dist/web/assets/AgentDemoPage-ClnGPAV9.js +0 -1
  138. package/dist/web/assets/ProjectKanbanPage-BddzfZRV.js +0 -87
  139. package/dist/web/assets/index-BGvfX18x.css +0 -1
  140. package/dist/web/assets/index-CHN8jahE.js +0 -13
  141. package/dist/web/assets/modal-D_AU4URz.js +0 -1
@@ -0,0 +1,1038 @@
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 createWorkRequest(options) {
198
+ return prisma.workRequest.create({
199
+ data: {
200
+ teamRunId: options.teamRunId,
201
+ requesterMemberId: null,
202
+ requesterType: 'user',
203
+ targetMemberId: options.targetMemberId,
204
+ triggerMessageId: `message-${Math.random().toString(16).slice(2)}`,
205
+ instruction: options.instruction ?? 'Please do the work',
206
+ ifBusy: 'queue',
207
+ cancelQueued: false,
208
+ status: options.status ?? 'STARTED',
209
+ createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, workRequestSequence++)),
210
+ },
211
+ });
212
+ }
213
+ async function createRunningInvocation(options) {
214
+ const session = options.sessionId
215
+ ? await prisma.session.findUniqueOrThrow({ where: { id: options.sessionId } })
216
+ : await prisma.session.create({
217
+ data: {
218
+ workspaceId: options.workspaceId,
219
+ agentType: 'CODEX',
220
+ providerId: 'provider-1',
221
+ prompt: 'Do the work',
222
+ status: 'COMPLETED',
223
+ },
224
+ });
225
+ return prisma.agentInvocation.create({
226
+ data: {
227
+ teamRunId: options.teamRunId,
228
+ workRequestId: options.workRequestId,
229
+ memberId: options.memberId,
230
+ workspaceId: options.workspaceId ?? session.workspaceId,
231
+ sessionId: session.id,
232
+ status: options.status ?? 'RUNNING',
233
+ roomReplyReminderCount: options.roomReplyReminderCount ?? 0,
234
+ },
235
+ });
236
+ }
237
+ describe('TeamReconcilerService', () => {
238
+ let lockService;
239
+ let scheduler;
240
+ let messenger;
241
+ let eventBus;
242
+ let service;
243
+ beforeAll(async () => {
244
+ execFileSync('pnpm', ['exec', 'prisma', 'db', 'push', '--skip-generate', `--schema=${schemaPath}`], {
245
+ cwd: serverRoot,
246
+ env: { ...process.env, AGENT_TOWER_DATABASE_URL: `file:${dbPath}` },
247
+ stdio: 'pipe',
248
+ });
249
+ const utilsModule = await import('../../utils/index.js');
250
+ const reconcilerModule = await import('../team-reconciler.service.js');
251
+ const sessionManagerModule = await import('../session-manager.js');
252
+ const mcpServerModule = await import('../../mcp/server.js');
253
+ const teamRunRoutesModule = await import('../../routes/team-runs.js');
254
+ prisma = utilsModule.prisma;
255
+ TeamReconcilerService = reconcilerModule.TeamReconcilerService;
256
+ SessionManager = sessionManagerModule.SessionManager;
257
+ TEAM_ROOM_REPLY_REMINDER = reconcilerModule.TEAM_ROOM_REPLY_REMINDER;
258
+ createMcpServer = mcpServerModule.createMcpServer;
259
+ teamRunRoutes = teamRunRoutesModule.teamRunRoutes;
260
+ });
261
+ beforeEach(async () => {
262
+ vi.restoreAllMocks();
263
+ workRequestSequence = 0;
264
+ lockService = new TeamLockService();
265
+ scheduler = createSchedulerMock(lockService);
266
+ messenger = createMessengerMock();
267
+ eventBus = new EventBus();
268
+ service = new TeamReconcilerService({
269
+ scheduler,
270
+ sessionMessenger: messenger,
271
+ eventBus,
272
+ now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, 0)),
273
+ reminderDelaysMs: [1_000, 2_000, 4_000],
274
+ maxRoomReplyReminders: 3,
275
+ scheduleReminders: false,
276
+ });
277
+ await prisma.agentInvocation.deleteMany();
278
+ await prisma.workRequest.deleteMany();
279
+ await prisma.roomMessage.deleteMany();
280
+ await prisma.teamMember.deleteMany();
281
+ await prisma.teamRun.deleteMany();
282
+ await prisma.session.deleteMany();
283
+ await prisma.workspace.deleteMany();
284
+ await prisma.task.deleteMany();
285
+ await prisma.project.deleteMany();
286
+ });
287
+ afterAll(async () => {
288
+ vi.restoreAllMocks();
289
+ await prisma.$disconnect();
290
+ fs.rmSync(testDir, { recursive: true, force: true });
291
+ });
292
+ it('marks invocation completed and releases locks when a RoomMessage exists for the invocation', async () => {
293
+ const { workspace, teamRun, members } = await createFixture();
294
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
295
+ const invocation = await createRunningInvocation({
296
+ teamRunId: teamRun.id,
297
+ workRequestId: request.id,
298
+ memberId: members[0].id,
299
+ workspaceId: workspace.id,
300
+ });
301
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
302
+ await prisma.roomMessage.create({
303
+ data: {
304
+ teamRunId: teamRun.id,
305
+ senderType: 'agent',
306
+ senderId: members[0].id,
307
+ senderInvocationId: invocation.id,
308
+ kind: 'chat',
309
+ content: 'Implemented the change',
310
+ mentions: '[]',
311
+ workRequestIds: '[]',
312
+ artifactRefs: '[]',
313
+ attachmentIds: '[]',
314
+ },
315
+ });
316
+ await service.handleSessionExit(invocation.sessionId);
317
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
318
+ status: 'COMPLETED',
319
+ nextRoomReplyReminderAt: null,
320
+ });
321
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
322
+ expect(lockService.listLocks()).toEqual([]);
323
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
324
+ });
325
+ it('marks invocation waiting, increments reminder count, and sends a reminder when no RoomMessage exists', async () => {
326
+ const { workspace, teamRun, members } = await createFixture();
327
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
328
+ const invocation = await createRunningInvocation({
329
+ teamRunId: teamRun.id,
330
+ workRequestId: request.id,
331
+ memberId: members[0].id,
332
+ workspaceId: workspace.id,
333
+ });
334
+ await service.handleSessionExit(invocation.sessionId);
335
+ const reloaded = await prisma.agentInvocation.findUniqueOrThrow({ where: { id: invocation.id } });
336
+ expect(reloaded.status).toBe('WAITING_ROOM_REPLY');
337
+ expect(reloaded.roomReplyReminderCount).toBe(1);
338
+ expect(reloaded.nextRoomReplyReminderAt?.toISOString()).toBe('2026-01-01T00:00:01.000Z');
339
+ expect(messenger.sendMessage).toHaveBeenCalledWith(invocation.sessionId, TEAM_ROOM_REPLY_REMINDER);
340
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
341
+ });
342
+ it('does not send another reminder before the backoff time is due', async () => {
343
+ const { workspace, teamRun, members } = await createFixture();
344
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
345
+ const invocation = await createRunningInvocation({
346
+ teamRunId: teamRun.id,
347
+ workRequestId: request.id,
348
+ memberId: members[0].id,
349
+ workspaceId: workspace.id,
350
+ status: 'WAITING_ROOM_REPLY',
351
+ roomReplyReminderCount: 1,
352
+ });
353
+ await prisma.agentInvocation.update({
354
+ where: { id: invocation.id },
355
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)) },
356
+ });
357
+ await service.reconcileInvocation(invocation.id);
358
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
359
+ status: 'WAITING_ROOM_REPLY',
360
+ roomReplyReminderCount: 1,
361
+ });
362
+ expect(messenger.sendMessage).not.toHaveBeenCalled();
363
+ });
364
+ it('marks invocation failed and releases locks when max due reminders are reached without a RoomMessage', async () => {
365
+ const { workspace, teamRun, members } = await createFixture();
366
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
367
+ const invocation = await createRunningInvocation({
368
+ teamRunId: teamRun.id,
369
+ workRequestId: request.id,
370
+ memberId: members[0].id,
371
+ workspaceId: workspace.id,
372
+ status: 'WAITING_ROOM_REPLY',
373
+ roomReplyReminderCount: 2,
374
+ });
375
+ await prisma.agentInvocation.update({
376
+ where: { id: invocation.id },
377
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2025, 11, 31, 23, 59, 59)) },
378
+ });
379
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
380
+ await expect(service.reconcileDueRoomReplyReminders()).resolves.toBe(1);
381
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
382
+ status: 'WAITING_ROOM_REPLY',
383
+ roomReplyReminderCount: 3,
384
+ });
385
+ expect(messenger.sendMessage).toHaveBeenCalledTimes(1);
386
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
387
+ await prisma.agentInvocation.update({
388
+ where: { id: invocation.id },
389
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2025, 11, 31, 23, 59, 59)) },
390
+ });
391
+ await expect(service.reconcileDueRoomReplyReminders()).resolves.toBe(1);
392
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
393
+ status: 'FAILED',
394
+ nextRoomReplyReminderAt: null,
395
+ });
396
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
397
+ expect(lockService.listLocks()).toEqual([]);
398
+ });
399
+ it('moves an idle TeamRun task from IN_PROGRESS to IN_REVIEW and writes reviewReason', async () => {
400
+ const { workspace, task, teamRun, members } = await createFixture({ taskStatus: TaskStatus.IN_PROGRESS });
401
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
402
+ const invocation = await createRunningInvocation({
403
+ teamRunId: teamRun.id,
404
+ workRequestId: request.id,
405
+ memberId: members[0].id,
406
+ workspaceId: workspace.id,
407
+ });
408
+ await prisma.roomMessage.create({
409
+ data: {
410
+ teamRunId: teamRun.id,
411
+ senderType: 'agent',
412
+ senderId: members[0].id,
413
+ senderInvocationId: invocation.id,
414
+ kind: 'chat',
415
+ content: 'Done',
416
+ mentions: '[]',
417
+ workRequestIds: '[]',
418
+ artifactRefs: '[]',
419
+ attachmentIds: '[]',
420
+ },
421
+ });
422
+ const taskUpdates = [];
423
+ eventBus.on('task:updated', (payload) => taskUpdates.push(payload));
424
+ await service.handleSessionExit(invocation.sessionId);
425
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
426
+ status: TaskStatus.IN_REVIEW,
427
+ });
428
+ await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
429
+ reviewReason: 'TEAM_QUIESCENT',
430
+ });
431
+ expect(taskUpdates).toEqual([
432
+ { taskId: task.id, projectId: expect.any(String), status: TaskStatus.IN_REVIEW },
433
+ ]);
434
+ });
435
+ it('cancels invocation, releases locks, starts queued work, and advances idle TeamRun on session stop', async () => {
436
+ const { workspace, task, teamRun, members } = await createFixture({ taskStatus: TaskStatus.IN_PROGRESS });
437
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
438
+ const invocation = await createRunningInvocation({
439
+ teamRunId: teamRun.id,
440
+ workRequestId: request.id,
441
+ memberId: members[0].id,
442
+ workspaceId: workspace.id,
443
+ status: 'RUNNING',
444
+ });
445
+ await prisma.agentInvocation.update({
446
+ where: { id: invocation.id },
447
+ data: { nextRoomReplyReminderAt: new Date(Date.UTC(2026, 0, 1, 0, 1, 0)) },
448
+ });
449
+ expect(lockService.acquire(invocation.id, ['workspace:task:write'])).toBe(true);
450
+ await expect(service.handleSessionStopped(invocation.sessionId)).resolves.toBe(true);
451
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
452
+ status: 'CANCELLED',
453
+ nextRoomReplyReminderAt: null,
454
+ });
455
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
456
+ expect(lockService.listLocks()).toEqual([]);
457
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
458
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
459
+ status: TaskStatus.IN_REVIEW,
460
+ });
461
+ await expect(prisma.teamRun.findUnique({ where: { id: teamRun.id } })).resolves.toMatchObject({
462
+ reviewReason: 'TEAM_QUIESCENT',
463
+ });
464
+ });
465
+ it('keeps stopped TeamRun out of review when queued work remains', async () => {
466
+ const { workspace, task, teamRun, members } = await createFixture({
467
+ taskStatus: TaskStatus.IN_PROGRESS,
468
+ memberCount: 2,
469
+ });
470
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
471
+ const invocation = await createRunningInvocation({
472
+ teamRunId: teamRun.id,
473
+ workRequestId: request.id,
474
+ memberId: members[0].id,
475
+ workspaceId: workspace.id,
476
+ status: 'RUNNING',
477
+ });
478
+ await createWorkRequest({
479
+ teamRunId: teamRun.id,
480
+ targetMemberId: members[1].id,
481
+ status: 'QUEUED',
482
+ });
483
+ await service.handleSessionStopped(invocation.sessionId);
484
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
485
+ status: 'CANCELLED',
486
+ });
487
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
488
+ status: TaskStatus.IN_PROGRESS,
489
+ });
490
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
491
+ });
492
+ it.each([
493
+ ['queued work request', async (teamRunId, memberId) => {
494
+ await createWorkRequest({ teamRunId, targetMemberId: memberId, status: 'QUEUED' });
495
+ }],
496
+ ['pending approval work request', async (teamRunId, memberId) => {
497
+ await createWorkRequest({ teamRunId, targetMemberId: memberId, status: 'PENDING_APPROVAL' });
498
+ }],
499
+ ['running invocation', async (teamRunId, memberId, workspaceId) => {
500
+ const request = await createWorkRequest({ teamRunId, targetMemberId: memberId });
501
+ await createRunningInvocation({ teamRunId, workRequestId: request.id, memberId, workspaceId, status: 'RUNNING' });
502
+ }],
503
+ ['waiting invocation', async (teamRunId, memberId, workspaceId) => {
504
+ const request = await createWorkRequest({ teamRunId, targetMemberId: memberId });
505
+ await createRunningInvocation({
506
+ teamRunId,
507
+ workRequestId: request.id,
508
+ memberId,
509
+ workspaceId,
510
+ status: 'WAITING_ROOM_REPLY',
511
+ });
512
+ }],
513
+ ])('does not move the task to review while the TeamRun still has %s', async (_caseName, arrange) => {
514
+ const { workspace, task, teamRun, members } = await createFixture({
515
+ taskStatus: TaskStatus.IN_PROGRESS,
516
+ memberCount: 2,
517
+ });
518
+ const completedRequest = await createWorkRequest({
519
+ teamRunId: teamRun.id,
520
+ targetMemberId: members[0].id,
521
+ status: 'STARTED',
522
+ });
523
+ await createRunningInvocation({
524
+ teamRunId: teamRun.id,
525
+ workRequestId: completedRequest.id,
526
+ memberId: members[0].id,
527
+ workspaceId: workspace.id,
528
+ status: 'COMPLETED',
529
+ });
530
+ await arrange(teamRun.id, members[1].id, workspace.id);
531
+ const advanced = await service.maybeAdvanceTeamRunToReview(teamRun.id);
532
+ expect(advanced).toBe(false);
533
+ await expect(prisma.task.findUnique({ where: { id: task.id } })).resolves.toMatchObject({
534
+ status: TaskStatus.IN_PROGRESS,
535
+ });
536
+ });
537
+ it('posts a room message from MCP env identity and creates WorkRequests through mentions', async () => {
538
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
539
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
540
+ const previousInvocationId = process.env.AGENT_TOWER_INVOCATION_ID;
541
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 2 });
542
+ const request = await createWorkRequest({
543
+ teamRunId: teamRun.id,
544
+ targetMemberId: members[0].id,
545
+ status: 'STARTED',
546
+ });
547
+ const invocation = await createRunningInvocation({
548
+ teamRunId: teamRun.id,
549
+ workRequestId: request.id,
550
+ memberId: members[0].id,
551
+ workspaceId: workspace.id,
552
+ });
553
+ const app = Fastify({ logger: false });
554
+ try {
555
+ process.env.AGENT_TOWER_TEAM_RUN_ID = teamRun.id;
556
+ process.env.AGENT_TOWER_MEMBER_ID = members[0].id;
557
+ process.env.AGENT_TOWER_INVOCATION_ID = invocation.id;
558
+ await app.register(teamRunRoutes, { prefix: '/api' });
559
+ await app.listen({ port: 0, host: '127.0.0.1' });
560
+ const address = app.server.address();
561
+ if (!address || typeof address === 'string') {
562
+ throw new Error('Failed to start test server');
563
+ }
564
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
565
+ const client = new Client({ name: 'team-room-test-client', version: '0.1.0' });
566
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
567
+ await server.connect(serverTransport);
568
+ await client.connect(clientTransport);
569
+ const result = await client.callTool({
570
+ name: 'post_room_message',
571
+ arguments: {
572
+ content: 'Implemented the parser and please review it',
573
+ mentions: [{ memberId: members[1].id }],
574
+ },
575
+ });
576
+ await client.close();
577
+ await server.close();
578
+ expect(result.isError).not.toBe(true);
579
+ const resultContent = result.content;
580
+ const messageText = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
581
+ const message = JSON.parse(messageText);
582
+ expect(message).toMatchObject({
583
+ senderType: 'agent',
584
+ senderInvocationId: invocation.id,
585
+ });
586
+ await expect(prisma.roomMessage.findUnique({ where: { id: message.id } })).resolves.toMatchObject({
587
+ senderType: 'agent',
588
+ senderId: members[0].id,
589
+ senderInvocationId: invocation.id,
590
+ });
591
+ await expect(prisma.workRequest.findFirst({
592
+ where: {
593
+ teamRunId: teamRun.id,
594
+ triggerMessageId: message.id,
595
+ targetMemberId: members[1].id,
596
+ },
597
+ })).resolves.toMatchObject({
598
+ requesterType: 'agent',
599
+ requesterMemberId: members[0].id,
600
+ status: 'QUEUED',
601
+ });
602
+ }
603
+ finally {
604
+ if (previousTeamRunId === undefined) {
605
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
606
+ }
607
+ else {
608
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
609
+ }
610
+ if (previousMemberId === undefined) {
611
+ delete process.env.AGENT_TOWER_MEMBER_ID;
612
+ }
613
+ else {
614
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
615
+ }
616
+ if (previousInvocationId === undefined) {
617
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
618
+ }
619
+ else {
620
+ process.env.AGENT_TOWER_INVOCATION_ID = previousInvocationId;
621
+ }
622
+ await app.close();
623
+ }
624
+ });
625
+ it('posts a room message from MCP workspace context identity when MCP env identity is missing', async () => {
626
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
627
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
628
+ const previousInvocationId = process.env.AGENT_TOWER_INVOCATION_ID;
629
+ const previousSessionId = process.env.AGENT_TOWER_SESSION_ID;
630
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 2 });
631
+ const request = await createWorkRequest({
632
+ teamRunId: teamRun.id,
633
+ targetMemberId: members[0].id,
634
+ status: 'STARTED',
635
+ });
636
+ const invocation = await createRunningInvocation({
637
+ teamRunId: teamRun.id,
638
+ workRequestId: request.id,
639
+ memberId: members[0].id,
640
+ workspaceId: workspace.id,
641
+ status: 'RUNNING',
642
+ });
643
+ const contextWorktreePath = fs.realpathSync(workspace.worktreePath);
644
+ await prisma.workspace.update({
645
+ where: { id: workspace.id },
646
+ data: { worktreePath: contextWorktreePath },
647
+ });
648
+ const app = Fastify({ logger: false });
649
+ try {
650
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
651
+ delete process.env.AGENT_TOWER_MEMBER_ID;
652
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
653
+ delete process.env.AGENT_TOWER_SESSION_ID;
654
+ await app.register((await import('../../routes/system.js')).systemRoutes, { prefix: '/api' });
655
+ await app.register(teamRunRoutes, { prefix: '/api' });
656
+ await app.listen({ port: 0, host: '127.0.0.1' });
657
+ const address = app.server.address();
658
+ if (!address || typeof address === 'string') {
659
+ throw new Error('Failed to start test server');
660
+ }
661
+ const previousCwd = process.cwd();
662
+ let server;
663
+ try {
664
+ process.chdir(contextWorktreePath);
665
+ server = await createMcpServer(`http://127.0.0.1:${address.port}`);
666
+ }
667
+ finally {
668
+ process.chdir(previousCwd);
669
+ }
670
+ const client = new Client({ name: 'team-room-context-test-client', version: '0.1.0' });
671
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
672
+ await server.connect(serverTransport);
673
+ await client.connect(clientTransport);
674
+ const result = await client.callTool({
675
+ name: 'post_room_message',
676
+ arguments: {
677
+ content: 'Implemented through context identity',
678
+ mentions: [{ memberId: members[1].id }],
679
+ },
680
+ });
681
+ await client.close();
682
+ await server.close();
683
+ expect(result.isError, JSON.stringify(result.content)).not.toBe(true);
684
+ const resultContent = result.content;
685
+ const messageText = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
686
+ const message = JSON.parse(messageText);
687
+ expect(message).toMatchObject({
688
+ senderType: 'agent',
689
+ senderInvocationId: invocation.id,
690
+ });
691
+ await expect(prisma.roomMessage.findUnique({ where: { id: message.id } })).resolves.toMatchObject({
692
+ senderType: 'agent',
693
+ senderId: members[0].id,
694
+ senderInvocationId: invocation.id,
695
+ });
696
+ await expect(prisma.workRequest.findFirst({
697
+ where: {
698
+ teamRunId: teamRun.id,
699
+ triggerMessageId: message.id,
700
+ targetMemberId: members[1].id,
701
+ },
702
+ })).resolves.toMatchObject({
703
+ requesterType: 'agent',
704
+ requesterMemberId: members[0].id,
705
+ status: 'QUEUED',
706
+ });
707
+ }
708
+ finally {
709
+ if (previousTeamRunId === undefined) {
710
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
711
+ }
712
+ else {
713
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
714
+ }
715
+ if (previousMemberId === undefined) {
716
+ delete process.env.AGENT_TOWER_MEMBER_ID;
717
+ }
718
+ else {
719
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
720
+ }
721
+ if (previousInvocationId === undefined) {
722
+ delete process.env.AGENT_TOWER_INVOCATION_ID;
723
+ }
724
+ else {
725
+ process.env.AGENT_TOWER_INVOCATION_ID = previousInvocationId;
726
+ }
727
+ if (previousSessionId === undefined) {
728
+ delete process.env.AGENT_TOWER_SESSION_ID;
729
+ }
730
+ else {
731
+ process.env.AGENT_TOWER_SESSION_ID = previousSessionId;
732
+ }
733
+ await app.close();
734
+ }
735
+ });
736
+ it('lists TeamRun members through MCP without exposing role prompts', async () => {
737
+ const previousTeamRunId = process.env.AGENT_TOWER_TEAM_RUN_ID;
738
+ const previousMemberId = process.env.AGENT_TOWER_MEMBER_ID;
739
+ const { teamRun, members } = await createFixture({ memberCount: 2 });
740
+ const app = Fastify({ logger: false });
741
+ try {
742
+ process.env.AGENT_TOWER_TEAM_RUN_ID = teamRun.id;
743
+ process.env.AGENT_TOWER_MEMBER_ID = members[0].id;
744
+ await app.register(teamRunRoutes, { prefix: '/api' });
745
+ await app.listen({ port: 0, host: '127.0.0.1' });
746
+ const address = app.server.address();
747
+ if (!address || typeof address === 'string') {
748
+ throw new Error('Failed to start test server');
749
+ }
750
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
751
+ const client = new Client({ name: 'team-member-test-client', version: '0.1.0' });
752
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
753
+ await server.connect(serverTransport);
754
+ await client.connect(clientTransport);
755
+ const result = await client.callTool({
756
+ name: 'list_team_members',
757
+ arguments: {},
758
+ });
759
+ await client.close();
760
+ await server.close();
761
+ expect(result.isError).not.toBe(true);
762
+ const resultContent = result.content;
763
+ const text = resultContent[0]?.type === 'text' ? resultContent[0].text ?? '' : '';
764
+ const payload = JSON.parse(text);
765
+ expect(payload).toMatchObject({
766
+ teamRunId: teamRun.id,
767
+ currentMemberId: members[0].id,
768
+ });
769
+ expect(payload.members).toHaveLength(2);
770
+ expect(payload.members[0]).toMatchObject({
771
+ id: members[0].id,
772
+ name: 'Member 1',
773
+ aliases: ['member-1'],
774
+ status: 'IDLE',
775
+ workspacePolicy: 'shared',
776
+ triggerPolicy: 'MENTION_ONLY',
777
+ sessionPolicy: 'new_per_request',
778
+ providerId: 'provider-1',
779
+ });
780
+ expect(payload.members[0]?.capabilities).toMatchObject({
781
+ writeFiles: true,
782
+ runCommands: false,
783
+ mentionMembers: true,
784
+ });
785
+ expect(payload.members[0]).not.toHaveProperty('rolePrompt');
786
+ expect(payload.members[0]).not.toHaveProperty('avatar');
787
+ expect(payload.members[0]).not.toHaveProperty('createdAt');
788
+ expect(payload.members[0]).not.toHaveProperty('updatedAt');
789
+ expect(payload.members[0]).not.toHaveProperty('presetId');
790
+ }
791
+ finally {
792
+ if (previousTeamRunId === undefined) {
793
+ delete process.env.AGENT_TOWER_TEAM_RUN_ID;
794
+ }
795
+ else {
796
+ process.env.AGENT_TOWER_TEAM_RUN_ID = previousTeamRunId;
797
+ }
798
+ if (previousMemberId === undefined) {
799
+ delete process.env.AGENT_TOWER_MEMBER_ID;
800
+ }
801
+ else {
802
+ process.env.AGENT_TOWER_MEMBER_ID = previousMemberId;
803
+ }
804
+ await app.close();
805
+ }
806
+ });
807
+ it('rejects explicit mismatched team_run_id in bound MCP TeamRun tools', async () => {
808
+ const previousEnv = captureTeamRunEnv();
809
+ const { workspace, teamRun, members } = await createFixture({ memberCount: 1 });
810
+ const other = await createFixture({ memberCount: 1 });
811
+ const request = await createWorkRequest({
812
+ teamRunId: teamRun.id,
813
+ targetMemberId: members[0].id,
814
+ status: 'STARTED',
815
+ });
816
+ const invocation = await createRunningInvocation({
817
+ teamRunId: teamRun.id,
818
+ workRequestId: request.id,
819
+ memberId: members[0].id,
820
+ workspaceId: workspace.id,
821
+ });
822
+ const routeScheduler = createRouteSchedulerMock();
823
+ const app = Fastify({ logger: false });
824
+ try {
825
+ setTeamRunEnv({
826
+ AGENT_TOWER_TEAM_RUN_ID: teamRun.id,
827
+ AGENT_TOWER_MEMBER_ID: members[0].id,
828
+ AGENT_TOWER_INVOCATION_ID: invocation.id,
829
+ AGENT_TOWER_SESSION_ID: undefined,
830
+ });
831
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
832
+ await app.listen({ port: 0, host: '127.0.0.1' });
833
+ const address = app.server.address();
834
+ if (!address || typeof address === 'string') {
835
+ throw new Error('Failed to start test server');
836
+ }
837
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
838
+ const client = new Client({ name: 'team-run-mismatch-test-client', version: '0.1.0' });
839
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
840
+ await server.connect(serverTransport);
841
+ await client.connect(clientTransport);
842
+ try {
843
+ const cases = [
844
+ {
845
+ name: 'post_room_message',
846
+ arguments: {
847
+ team_run_id: other.teamRun.id,
848
+ content: 'This should be rejected before reaching the API',
849
+ },
850
+ },
851
+ {
852
+ name: 'list_room_messages',
853
+ arguments: {
854
+ team_run_id: other.teamRun.id,
855
+ },
856
+ },
857
+ {
858
+ name: 'list_team_members',
859
+ arguments: {
860
+ team_run_id: other.teamRun.id,
861
+ },
862
+ },
863
+ {
864
+ name: 'stop_member_work',
865
+ arguments: {
866
+ team_run_id: other.teamRun.id,
867
+ member_id: other.members[0].id,
868
+ cancel_queued: true,
869
+ },
870
+ },
871
+ ];
872
+ for (const toolCall of cases) {
873
+ const result = await client.callTool(toolCall);
874
+ expect(result.isError, toolCall.name).toBe(true);
875
+ expect(getMcpToolText(result), toolCall.name).toContain(TEAM_RUN_MISMATCH_ERROR);
876
+ }
877
+ }
878
+ finally {
879
+ await client.close();
880
+ await server.close();
881
+ }
882
+ await expect(prisma.roomMessage.count()).resolves.toBe(0);
883
+ expect(routeScheduler.stopMemberWork).not.toHaveBeenCalled();
884
+ }
885
+ finally {
886
+ restoreTeamRunEnv(previousEnv);
887
+ await app.close();
888
+ }
889
+ });
890
+ it('posts MCP room messages as user when bound TeamRun identity is partial', async () => {
891
+ const previousEnv = captureTeamRunEnv();
892
+ const { teamRun, members } = await createFixture({ memberCount: 2 });
893
+ const app = Fastify({ logger: false });
894
+ try {
895
+ setTeamRunEnv({
896
+ AGENT_TOWER_TEAM_RUN_ID: teamRun.id,
897
+ AGENT_TOWER_MEMBER_ID: members[0].id,
898
+ AGENT_TOWER_INVOCATION_ID: undefined,
899
+ AGENT_TOWER_SESSION_ID: undefined,
900
+ });
901
+ await app.register(teamRunRoutes, { prefix: '/api' });
902
+ await app.listen({ port: 0, host: '127.0.0.1' });
903
+ const address = app.server.address();
904
+ if (!address || typeof address === 'string') {
905
+ throw new Error('Failed to start test server');
906
+ }
907
+ const server = await createMcpServer(`http://127.0.0.1:${address.port}`);
908
+ const client = new Client({ name: 'team-run-partial-identity-test-client', version: '0.1.0' });
909
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
910
+ await server.connect(serverTransport);
911
+ await client.connect(clientTransport);
912
+ let messageId;
913
+ try {
914
+ const result = await client.callTool({
915
+ name: 'post_room_message',
916
+ arguments: {
917
+ content: 'Partial identity should not be sent as agent',
918
+ },
919
+ });
920
+ expect(result.isError, getMcpToolText(result)).not.toBe(true);
921
+ const message = JSON.parse(getMcpToolText(result));
922
+ messageId = message.id;
923
+ expect(message).toMatchObject({
924
+ senderType: 'user',
925
+ senderId: null,
926
+ senderInvocationId: null,
927
+ });
928
+ }
929
+ finally {
930
+ await client.close();
931
+ await server.close();
932
+ }
933
+ await expect(prisma.roomMessage.findUnique({ where: { id: messageId } })).resolves.toMatchObject({
934
+ senderType: 'user',
935
+ senderId: null,
936
+ senderInvocationId: null,
937
+ });
938
+ }
939
+ finally {
940
+ restoreTeamRunEnv(previousEnv);
941
+ await app.close();
942
+ }
943
+ });
944
+ it('auto-starts USER_MESSAGES work when a user posts an unmentioned room message', async () => {
945
+ const { teamRun, members } = await createFixture({
946
+ memberCount: 2,
947
+ teamRunMode: 'AUTO',
948
+ triggerPolicies: ['USER_MESSAGES', 'MENTION_ONLY'],
949
+ });
950
+ const routeScheduler = createRouteSchedulerMock();
951
+ const app = Fastify({ logger: false });
952
+ try {
953
+ await app.register(teamRunRoutes, { prefix: '/api', scheduler: routeScheduler });
954
+ const response = await app.inject({
955
+ method: 'POST',
956
+ url: `/api/team-runs/${teamRun.id}/messages`,
957
+ payload: {
958
+ content: '普通用户消息',
959
+ senderType: 'user',
960
+ },
961
+ });
962
+ expect(response.statusCode).toBe(201);
963
+ const message = response.json();
964
+ expect(message.mentions).toEqual([]);
965
+ expect(message.workRequestIds).toHaveLength(1);
966
+ expect(routeScheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
967
+ expect(routeScheduler.startedTeamRunIds).toEqual([teamRun.id]);
968
+ await expect(prisma.workRequest.findUnique({ where: { id: message.workRequestIds[0] } })).resolves.toMatchObject({
969
+ targetMemberId: members[0].id,
970
+ instruction: '普通用户消息',
971
+ status: 'STARTED',
972
+ });
973
+ await expect(prisma.agentInvocation.count({
974
+ where: {
975
+ teamRunId: teamRun.id,
976
+ memberId: members[0].id,
977
+ status: 'RUNNING',
978
+ },
979
+ })).resolves.toBe(1);
980
+ }
981
+ finally {
982
+ await app.close();
983
+ }
984
+ });
985
+ it('SessionManager.stop routes TeamRun sessions through the reconciler', async () => {
986
+ const { workspace, teamRun, members } = await createFixture();
987
+ const request = await createWorkRequest({ teamRunId: teamRun.id, targetMemberId: members[0].id });
988
+ const session = await prisma.session.create({
989
+ data: {
990
+ workspaceId: workspace.id,
991
+ agentType: 'CODEX',
992
+ providerId: 'provider-1',
993
+ prompt: 'Do the work',
994
+ status: 'RUNNING',
995
+ },
996
+ });
997
+ const invocation = await createRunningInvocation({
998
+ teamRunId: teamRun.id,
999
+ workRequestId: request.id,
1000
+ memberId: members[0].id,
1001
+ workspaceId: workspace.id,
1002
+ sessionId: session.id,
1003
+ status: 'RUNNING',
1004
+ });
1005
+ const manager = new SessionManager(new EventBus(), service);
1006
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
1007
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
1008
+ status: 'CANCELLED',
1009
+ });
1010
+ await expect(prisma.agentInvocation.findUnique({ where: { id: invocation.id } })).resolves.toMatchObject({
1011
+ status: 'CANCELLED',
1012
+ nextRoomReplyReminderAt: null,
1013
+ });
1014
+ expect(scheduler.releaseInvocationLocks).toHaveBeenCalledWith(invocation.id);
1015
+ expect(scheduler.startNextSessions).toHaveBeenCalledWith(teamRun.id);
1016
+ });
1017
+ it('SessionManager.stop leaves solo sessions without TeamRun reconciliation', async () => {
1018
+ const { workspace } = await createFixture();
1019
+ const session = await prisma.session.create({
1020
+ data: {
1021
+ workspaceId: workspace.id,
1022
+ agentType: 'CODEX',
1023
+ providerId: 'provider-1',
1024
+ prompt: 'Solo work',
1025
+ status: 'RUNNING',
1026
+ },
1027
+ });
1028
+ const manager = new SessionManager(new EventBus(), service);
1029
+ await expect(manager.stop(session.id)).resolves.toMatchObject({ id: session.id });
1030
+ await expect(prisma.session.findUnique({ where: { id: session.id } })).resolves.toMatchObject({
1031
+ status: 'CANCELLED',
1032
+ });
1033
+ expect(scheduler.releaseInvocationLocks).not.toHaveBeenCalled();
1034
+ expect(scheduler.startNextSessions).not.toHaveBeenCalled();
1035
+ await expect(prisma.agentInvocation.count()).resolves.toBe(0);
1036
+ });
1037
+ });
1038
+ //# sourceMappingURL=team-reconciler.service.test.js.map