@desplega.ai/agent-swarm 1.57.4 → 1.58.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 (36) hide show
  1. package/openapi.json +1 -1
  2. package/package.json +1 -1
  3. package/src/agentmail/handlers.ts +20 -0
  4. package/src/be/db.ts +265 -2
  5. package/src/be/migrations/031_user_registry.sql +35 -0
  6. package/src/commands/runner.ts +8 -1
  7. package/src/github/handlers.ts +18 -1
  8. package/src/gitlab/handlers.ts +12 -1
  9. package/src/hooks/hook.ts +17 -2
  10. package/src/http/poll.ts +9 -0
  11. package/src/linear/sync.ts +25 -1
  12. package/src/server.ts +6 -0
  13. package/src/slack/actions.ts +10 -1
  14. package/src/slack/assistant.ts +7 -0
  15. package/src/slack/handlers.ts +9 -2
  16. package/src/tests/events-http.test.ts +2 -2
  17. package/src/tests/http-api-integration.test.ts +3 -3
  18. package/src/tests/linear-outbound-sync.test.ts +7 -7
  19. package/src/tests/preload.ts +14 -0
  20. package/src/tests/rest-api.test.ts +1 -1
  21. package/src/tests/user-identity.test.ts +306 -0
  22. package/src/tests/workflow-async-v2.test.ts +7 -7
  23. package/src/tests/workflow-engine-v2.test.ts +3 -3
  24. package/src/tests/workflow-hitl-routing.test.ts +7 -7
  25. package/src/tests/workflow-http-v2.test.ts +1 -1
  26. package/src/tests/workflow-retry-v2.test.ts +4 -4
  27. package/src/tests/workflow-retry-validation.test.ts +3 -3
  28. package/src/tests/workflow-validation-port-routing.test.ts +221 -0
  29. package/src/tools/get-task-details.ts +14 -1
  30. package/src/tools/manage-user.ts +172 -0
  31. package/src/tools/resolve-user.ts +55 -0
  32. package/src/tools/tool-config.ts +4 -0
  33. package/src/types.ts +26 -0
  34. package/src/workflows/engine.ts +18 -4
  35. package/src/workflows/executors/script.ts +16 -1
  36. package/src/workflows/validation.ts +6 -3
@@ -1,4 +1,4 @@
1
- import { cancelTask, createTaskExtended, getAllAgents, getTaskById } from "../be/db";
1
+ import { cancelTask, createTaskExtended, getAllAgents, getTaskById, resolveUser } from "../be/db";
2
2
  import { getOAuthTokens } from "../be/db-queries/oauth";
3
3
  import {
4
4
  createTrackerSync,
@@ -269,6 +269,17 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
269
269
  return;
270
270
  }
271
271
 
272
+ // Extract actor identity from Linear webhook payload
273
+ const actor = event.actor as Record<string, unknown> | undefined;
274
+ const actorLinearId = actor ? String(actor.id ?? "") : "";
275
+ const actorName = actor ? String(actor.name ?? "") : "";
276
+ const actorEmail = actor ? String(actor.email ?? "") : "";
277
+ const requestedByUserId = resolveUser({
278
+ linearUserId: actorLinearId || undefined,
279
+ email: actorEmail || undefined,
280
+ name: actorName || undefined,
281
+ })?.id;
282
+
272
283
  // Check if we already track this issue
273
284
  const existing = getTrackerSyncByExternalId("linear", "task", issueId);
274
285
  const sessionId = agentSession ? String(agentSession.id ?? "") : "";
@@ -320,6 +331,7 @@ export async function handleAgentSessionEvent(event: Record<string, unknown>): P
320
331
  agentId: lead?.id ?? "",
321
332
  source: "linear",
322
333
  taskType: "linear-issue",
334
+ requestedByUserId,
323
335
  });
324
336
 
325
337
  // Delete old tracker_sync before creating new one (UNIQUE constraint)
@@ -536,6 +548,17 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
536
548
  // Task is completed/failed/cancelled or doesn't exist — create a new follow-up task
537
549
  const lead = findLeadAgent();
538
550
 
551
+ // Extract actor identity from Linear webhook payload
552
+ const promptedActor = event.actor as Record<string, unknown> | undefined;
553
+ const promptedActorLinearId = promptedActor ? String(promptedActor.id ?? "") : "";
554
+ const promptedActorEmail = promptedActor ? String(promptedActor.email ?? "") : "";
555
+ const promptedActorName = promptedActor ? String(promptedActor.name ?? "") : "";
556
+ const promptedRequestedByUserId = resolveUser({
557
+ linearUserId: promptedActorLinearId || undefined,
558
+ email: promptedActorEmail || undefined,
559
+ name: promptedActorName || undefined,
560
+ })?.id;
561
+
539
562
  const followupResult = resolveTemplate("linear.issue.followup", {
540
563
  issue_identifier: issueIdentifier,
541
564
  issue_title: issueTitle,
@@ -551,6 +574,7 @@ export async function handleAgentSessionPrompted(event: Record<string, unknown>)
551
574
  agentId: lead?.id ?? "",
552
575
  source: "linear",
553
576
  taskType: "linear-issue",
577
+ requestedByUserId: promptedRequestedByUserId,
554
578
  });
555
579
 
556
580
  // Repoint the existing tracker_sync to the new follow-up task (can't create a
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import { registerJoinSwarmTool } from "./tools/join-swarm";
15
15
  // Messaging capability
16
16
  import { registerListChannelsTool } from "./tools/list-channels";
17
17
  import { registerListServicesTool } from "./tools/list-services";
18
+ import { registerManageUserTool } from "./tools/manage-user";
18
19
  // MCP Servers capability
19
20
  import {
20
21
  registerMcpServerCreateTool,
@@ -44,6 +45,7 @@ import { registerRegisterAgentMailInboxTool } from "./tools/register-agentmail-i
44
45
  // Services capability
45
46
  import { registerRegisterServiceTool } from "./tools/register-service";
46
47
  import { registerRequestHumanInputTool } from "./tools/request-human-input";
48
+ import { registerResolveUserTool } from "./tools/resolve-user";
47
49
  // Scheduling capability
48
50
  import {
49
51
  registerCreateScheduleTool,
@@ -156,6 +158,10 @@ export function createServer() {
156
158
  registerMyAgentInfoTool(server);
157
159
  registerCancelTaskTool(server);
158
160
 
161
+ // User identity tools - always registered
162
+ registerResolveUserTool(server);
163
+ registerManageUserTool(server); // self-guards with lead check
164
+
159
165
  // Debug tools - always registered (self-guards with lead check)
160
166
  registerDbQueryTool(server);
161
167
 
@@ -1,5 +1,12 @@
1
1
  import type { App } from "@slack/bolt";
2
- import { cancelTask, createTaskExtended, getAgentById, getLeadAgent, getTaskById } from "../be/db";
2
+ import {
3
+ cancelTask,
4
+ createTaskExtended,
5
+ getAgentById,
6
+ getLeadAgent,
7
+ getTaskById,
8
+ resolveUser,
9
+ } from "../be/db";
3
10
  import { buildCancelledBlocks, getTaskLink } from "./blocks";
4
11
 
5
12
  export function registerActionHandlers(app: App): void {
@@ -65,6 +72,7 @@ export function registerActionHandlers(app: App): void {
65
72
  if (!originalTask || !originalTask.slackChannelId) return;
66
73
 
67
74
  const lead = getLeadAgent();
75
+ const requestedByUserId = resolveUser({ slackUserId: body.user.id })?.id;
68
76
  const followUpTask = createTaskExtended(followUpText, {
69
77
  agentId: lead?.id,
70
78
  source: "slack",
@@ -72,6 +80,7 @@ export function registerActionHandlers(app: App): void {
72
80
  slackChannelId: originalTask.slackChannelId,
73
81
  slackThreadTs: originalTask.slackThreadTs,
74
82
  slackUserId: body.user.id,
83
+ requestedByUserId,
75
84
  });
76
85
 
77
86
  const taskLink = getTaskLink(followUpTask.id);
@@ -4,6 +4,7 @@ import {
4
4
  getAgentWorkingOnThread,
5
5
  getLeadAgent,
6
6
  getMostRecentTaskInThread,
7
+ resolveUser,
7
8
  } from "../be/db";
8
9
  import { resolveTemplate } from "../prompts/resolver";
9
10
  import { bufferThreadMessage } from "./thread-buffer";
@@ -47,6 +48,9 @@ export function createAssistant(): Assistant {
47
48
  const messageText = (msg.text as string) || "";
48
49
  const userId = (msg.user as string) || "";
49
50
 
51
+ // Resolve canonical user identity (graceful — null if not found)
52
+ const requestedByUserId = userId ? resolveUser({ slackUserId: userId })?.id : undefined;
53
+
50
54
  // 1. Check if an agent is already working in this thread
51
55
  const workingAgent = getAgentWorkingOnThread(channelId, threadTs);
52
56
 
@@ -67,6 +71,7 @@ export function createAssistant(): Assistant {
67
71
  slackThreadTs: threadTs,
68
72
  slackUserId: userId,
69
73
  parentTaskId: latestTask?.id,
74
+ requestedByUserId,
70
75
  });
71
76
 
72
77
  await setStatus("Processing follow-up...");
@@ -96,6 +101,7 @@ export function createAssistant(): Assistant {
96
101
  slackChannelId: channelId,
97
102
  slackThreadTs: threadTs,
98
103
  slackUserId: userId,
104
+ requestedByUserId,
99
105
  });
100
106
  const offlineResult = resolveTemplate("slack.assistant.offline", {});
101
107
  await say(offlineResult.text);
@@ -108,6 +114,7 @@ export function createAssistant(): Assistant {
108
114
  slackChannelId: channelId,
109
115
  slackThreadTs: threadTs,
110
116
  slackUserId: userId,
117
+ requestedByUserId,
111
118
  });
112
119
  // setStatus shows typing indicator — watcher will post final result when done
113
120
  } catch (error) {
@@ -1,13 +1,13 @@
1
1
  import type { App } from "@slack/bolt";
2
2
  import type { WebClient } from "@slack/web-api";
3
3
  import {
4
- createTask,
5
4
  createTaskExtended,
6
5
  getAgentById,
7
6
  getAgentWorkingOnThread,
8
7
  getLeadAgent,
9
8
  getMostRecentTaskInThread,
10
9
  getTasksByAgentId,
10
+ resolveUser,
11
11
  } from "../be/db";
12
12
  import { resolveTemplate } from "../prompts/resolver";
13
13
  import { workflowEventBus } from "../workflows/event-bus";
@@ -336,6 +336,9 @@ export function registerMessageHandler(app: App): void {
336
336
  return;
337
337
  }
338
338
 
339
+ // Resolve canonical user identity (graceful — null if not found)
340
+ const requestedByUserId = resolveUser({ slackUserId: msg.user })?.id;
341
+
339
342
  // Emit workflow trigger event for Slack messages
340
343
  workflowEventBus.emit("slack.message", {
341
344
  channel: msg.channel,
@@ -465,6 +468,7 @@ export function registerMessageHandler(app: App): void {
465
468
  slackChannelId: msg.channel,
466
469
  slackThreadTs: threadTs,
467
470
  slackUserId: msg.user,
471
+ requestedByUserId,
468
472
  });
469
473
 
470
474
  await say({
@@ -537,17 +541,20 @@ export function registerMessageHandler(app: App): void {
537
541
  slackThreadTs: threadTs,
538
542
  slackUserId: msg.user,
539
543
  parentTaskId: latestTask?.id,
544
+ requestedByUserId,
540
545
  });
541
546
  results.assigned.push({ agentName: agent.name, taskId: task.id });
542
547
  continue;
543
548
  }
544
549
 
545
550
  // Workers receive tasks as before
546
- const task = createTask(agent.id, fullTaskDescription, {
551
+ const task = createTaskExtended(fullTaskDescription, {
552
+ agentId: agent.id,
547
553
  source: "slack",
548
554
  slackChannelId: msg.channel,
549
555
  slackThreadTs: threadTs,
550
556
  slackUserId: msg.user,
557
+ requestedByUserId,
551
558
  });
552
559
 
553
560
  // Check if agent has an in-progress task in this thread (queued follow-up)
@@ -38,7 +38,7 @@ async function waitForServer(url: string, timeoutMs = 15000) {
38
38
  const r = await fetch(url);
39
39
  if (r.ok) return;
40
40
  } catch {}
41
- await Bun.sleep(200);
41
+ await Bun.sleep(50);
42
42
  }
43
43
  throw new Error(`Server did not start within ${timeoutMs}ms`);
44
44
  }
@@ -74,7 +74,7 @@ afterAll(async () => {
74
74
  await serverProc.exited;
75
75
  } catch {}
76
76
  }
77
- await Bun.sleep(300);
77
+ await Bun.sleep(50);
78
78
  try {
79
79
  await unlink(TEST_DB_PATH);
80
80
  await unlink(`${TEST_DB_PATH}-wal`);
@@ -64,7 +64,7 @@ async function waitForServer(url: string, timeoutMs = 15000) {
64
64
  } catch {
65
65
  // not ready yet
66
66
  }
67
- await Bun.sleep(200);
67
+ await Bun.sleep(50);
68
68
  }
69
69
  throw new Error(`Server did not start within ${timeoutMs}ms`);
70
70
  }
@@ -128,7 +128,7 @@ afterAll(async () => {
128
128
  await serverProc.exited;
129
129
  } catch {}
130
130
  }
131
- await Bun.sleep(300);
131
+ await Bun.sleep(50);
132
132
  try {
133
133
  await unlink(TEST_DB_PATH);
134
134
  } catch {}
@@ -1557,7 +1557,7 @@ describe("AgentMail Webhooks (with filters)", () => {
1557
1557
  await agentmailProc.exited;
1558
1558
  } catch {}
1559
1559
  }
1560
- await Bun.sleep(300);
1560
+ await Bun.sleep(50);
1561
1561
  try {
1562
1562
  await unlink(AGENTMAIL_DB);
1563
1563
  } catch {}
@@ -54,7 +54,7 @@ describe("Linear Outbound Sync", () => {
54
54
  });
55
55
 
56
56
  // Allow async handler to run
57
- await new Promise((resolve) => setTimeout(resolve, 50));
57
+ await new Promise((resolve) => setTimeout(resolve, 10));
58
58
 
59
59
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
60
60
  const callArgs = mockCreateComment.mock.calls[0] as unknown[];
@@ -82,7 +82,7 @@ describe("Linear Outbound Sync", () => {
82
82
  failureReason: "Build error in module X",
83
83
  });
84
84
 
85
- await new Promise((resolve) => setTimeout(resolve, 50));
85
+ await new Promise((resolve) => setTimeout(resolve, 10));
86
86
 
87
87
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
88
88
  const callArgs = mockCreateComment.mock.calls[0] as unknown[];
@@ -98,7 +98,7 @@ describe("Linear Outbound Sync", () => {
98
98
  output: "done",
99
99
  });
100
100
 
101
- await new Promise((resolve) => setTimeout(resolve, 50));
101
+ await new Promise((resolve) => setTimeout(resolve, 10));
102
102
 
103
103
  expect(mockCreateComment).not.toHaveBeenCalled();
104
104
  });
@@ -123,7 +123,7 @@ describe("Linear Outbound Sync", () => {
123
123
  output: "done",
124
124
  });
125
125
 
126
- await new Promise((resolve) => setTimeout(resolve, 50));
126
+ await new Promise((resolve) => setTimeout(resolve, 10));
127
127
 
128
128
  expect(mockCreateComment).not.toHaveBeenCalled();
129
129
  });
@@ -148,7 +148,7 @@ describe("Linear Outbound Sync", () => {
148
148
  output: "done",
149
149
  });
150
150
 
151
- await new Promise((resolve) => setTimeout(resolve, 50));
151
+ await new Promise((resolve) => setTimeout(resolve, 10));
152
152
 
153
153
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
154
154
  });
@@ -172,7 +172,7 @@ describe("Linear Outbound Sync", () => {
172
172
  output: "done",
173
173
  });
174
174
 
175
- await new Promise((resolve) => setTimeout(resolve, 50));
175
+ await new Promise((resolve) => setTimeout(resolve, 10));
176
176
 
177
177
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
178
178
  });
@@ -193,7 +193,7 @@ describe("Linear Outbound Sync", () => {
193
193
  output: "done",
194
194
  });
195
195
 
196
- await new Promise((resolve) => setTimeout(resolve, 50));
196
+ await new Promise((resolve) => setTimeout(resolve, 10));
197
197
 
198
198
  expect(mockCreateComment).not.toHaveBeenCalled();
199
199
  });
@@ -0,0 +1,14 @@
1
+ import { closeDb, getDb, initDb } from "../be/db";
2
+
3
+ // Prevent tests from making real network calls to LLM providers.
4
+ // The RawLlmExecutor tests already handle both success and failure paths,
5
+ // so removing the key just forces the fast failure path (~0ms vs ~2s of API calls).
6
+ delete process.env.OPENROUTER_API_KEY;
7
+
8
+ // Build one fully-migrated AND fully-seeded SQLite template per worker.
9
+ // initDb runs all migrations, ensureAgentProfileColumns, seedContextVersions,
10
+ // seedDefaultTemplates, etc. We serialize the result so each test suite can
11
+ // restore from it instantly — no per-suite migration or seeding work at all.
12
+ initDb(":memory:");
13
+ (globalThis as any).__testMigrationTemplate = getDb().serialize();
14
+ closeDb();
@@ -599,7 +599,7 @@ describe("REST API Endpoints", () => {
599
599
  test("should return empty stats for empty database", async () => {
600
600
  // Clean up the database for this test
601
601
  closeDb();
602
- await unlink(TEST_DB_PATH);
602
+ await unlink(TEST_DB_PATH).catch(() => {});
603
603
  await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
604
604
  await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
605
605
  initDb(TEST_DB_PATH);
@@ -0,0 +1,306 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import {
4
+ closeDb,
5
+ createAgent,
6
+ createTaskExtended,
7
+ createUser,
8
+ deleteUser,
9
+ getAllUsers,
10
+ getTaskById,
11
+ getUserById,
12
+ initDb,
13
+ resolveUser,
14
+ updateUser,
15
+ } from "../be/db";
16
+
17
+ const TEST_DB_PATH = "./test-user-identity.sqlite";
18
+
19
+ let leadAgent: ReturnType<typeof createAgent>;
20
+ let workerAgent: ReturnType<typeof createAgent>;
21
+
22
+ beforeAll(() => {
23
+ initDb(TEST_DB_PATH);
24
+ leadAgent = createAgent({ name: "TestLead", isLead: true, status: "idle" });
25
+ workerAgent = createAgent({ name: "TestWorker", isLead: false, status: "idle" });
26
+ });
27
+
28
+ afterAll(() => {
29
+ closeDb();
30
+ try {
31
+ unlinkSync(TEST_DB_PATH);
32
+ unlinkSync(`${TEST_DB_PATH}-wal`);
33
+ unlinkSync(`${TEST_DB_PATH}-shm`);
34
+ } catch {
35
+ // ignore
36
+ }
37
+ });
38
+
39
+ // ─── User CRUD ────────────────────────────────────────────────────────────────
40
+
41
+ describe("createUser", () => {
42
+ test("creates a user with required fields only", () => {
43
+ const user = createUser({ name: "Alice" });
44
+ expect(user.id).toBeDefined();
45
+ expect(user.name).toBe("Alice");
46
+ expect(user.email).toBeUndefined();
47
+ expect(user.role).toBeUndefined();
48
+ expect(user.emailAliases).toEqual([]);
49
+ expect(user.preferredChannel).toBe("slack");
50
+ expect(user.createdAt).toBeDefined();
51
+ expect(user.lastUpdatedAt).toBeDefined();
52
+ });
53
+
54
+ test("creates a user with all fields", () => {
55
+ const user = createUser({
56
+ name: "Bob",
57
+ email: "bob@example.com",
58
+ role: "engineer",
59
+ notes: "Test user",
60
+ slackUserId: "U_BOB",
61
+ linearUserId: "lin-bob-uuid",
62
+ githubUsername: "bob-gh",
63
+ gitlabUsername: "bob-gl",
64
+ emailAliases: ["bob2@example.com", "robert@example.com"],
65
+ preferredChannel: "email",
66
+ timezone: "America/New_York",
67
+ });
68
+ expect(user.name).toBe("Bob");
69
+ expect(user.email).toBe("bob@example.com");
70
+ expect(user.role).toBe("engineer");
71
+ expect(user.notes).toBe("Test user");
72
+ expect(user.slackUserId).toBe("U_BOB");
73
+ expect(user.linearUserId).toBe("lin-bob-uuid");
74
+ expect(user.githubUsername).toBe("bob-gh");
75
+ expect(user.gitlabUsername).toBe("bob-gl");
76
+ expect(user.emailAliases).toEqual(["bob2@example.com", "robert@example.com"]);
77
+ expect(user.preferredChannel).toBe("email");
78
+ expect(user.timezone).toBe("America/New_York");
79
+ });
80
+
81
+ test("rejects duplicate slackUserId", () => {
82
+ createUser({ name: "First", slackUserId: "U_UNIQUE" });
83
+ expect(() => createUser({ name: "Second", slackUserId: "U_UNIQUE" })).toThrow();
84
+ });
85
+
86
+ test("rejects duplicate githubUsername", () => {
87
+ createUser({ name: "GH1", githubUsername: "unique-gh" });
88
+ expect(() => createUser({ name: "GH2", githubUsername: "unique-gh" })).toThrow();
89
+ });
90
+ });
91
+
92
+ describe("getUserById", () => {
93
+ test("returns user by ID", () => {
94
+ const created = createUser({ name: "GetById" });
95
+ const fetched = getUserById(created.id);
96
+ expect(fetched).toBeDefined();
97
+ expect(fetched!.name).toBe("GetById");
98
+ expect(fetched!.id).toBe(created.id);
99
+ });
100
+
101
+ test("returns null for non-existent ID", () => {
102
+ expect(getUserById("nonexistent")).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("getAllUsers", () => {
107
+ test("returns all users", () => {
108
+ const users = getAllUsers();
109
+ expect(users.length).toBeGreaterThan(0);
110
+ });
111
+ });
112
+
113
+ describe("updateUser", () => {
114
+ test("updates specific fields", () => {
115
+ const user = createUser({ name: "UpdateMe", role: "intern" });
116
+ const updated = updateUser(user.id, { role: "senior", email: "updated@test.com" });
117
+ expect(updated).toBeDefined();
118
+ expect(updated!.role).toBe("senior");
119
+ expect(updated!.email).toBe("updated@test.com");
120
+ expect(updated!.name).toBe("UpdateMe"); // unchanged
121
+ });
122
+
123
+ test("updates emailAliases", () => {
124
+ const user = createUser({ name: "AliasUser" });
125
+ const updated = updateUser(user.id, { emailAliases: ["alias1@test.com", "alias2@test.com"] });
126
+ expect(updated!.emailAliases).toEqual(["alias1@test.com", "alias2@test.com"]);
127
+ });
128
+
129
+ test("returns null for non-existent user", () => {
130
+ expect(updateUser("nonexistent", { name: "Nope" })).toBeNull();
131
+ });
132
+
133
+ test("returns unchanged user when no updates provided", () => {
134
+ const user = createUser({ name: "NoChange" });
135
+ const result = updateUser(user.id, {});
136
+ expect(result).toBeDefined();
137
+ expect(result!.name).toBe("NoChange");
138
+ });
139
+ });
140
+
141
+ describe("deleteUser", () => {
142
+ test("deletes existing user", () => {
143
+ const user = createUser({ name: "DeleteMe" });
144
+ expect(deleteUser(user.id)).toBe(true);
145
+ expect(getUserById(user.id)).toBeNull();
146
+ });
147
+
148
+ test("returns false for non-existent user", () => {
149
+ expect(deleteUser("nonexistent")).toBe(false);
150
+ });
151
+
152
+ test("clears requestedByUserId on tasks when user is deleted", () => {
153
+ const user = createUser({ name: "TaskOwner", slackUserId: "U_TASKOWNER" });
154
+ const task = createTaskExtended("test task with user", {
155
+ agentId: workerAgent.id,
156
+ source: "slack",
157
+ requestedByUserId: user.id,
158
+ });
159
+ expect(getTaskById(task.id)!.requestedByUserId).toBe(user.id);
160
+
161
+ deleteUser(user.id);
162
+ expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
163
+ });
164
+ });
165
+
166
+ // ─── resolveUser ──────────────────────────────────────────────────────────────
167
+
168
+ describe("resolveUser", () => {
169
+ let testUser: ReturnType<typeof createUser>;
170
+
171
+ beforeAll(() => {
172
+ testUser = createUser({
173
+ name: "Resolve TestUser",
174
+ email: "resolve-test@example.com",
175
+ slackUserId: "U_RESOLVE_SLACK",
176
+ linearUserId: "lin-resolve-uuid",
177
+ githubUsername: "resolve-gh",
178
+ gitlabUsername: "resolve-gl",
179
+ emailAliases: ["resolve-alias@example.com"],
180
+ });
181
+ });
182
+
183
+ test("resolves by slackUserId", () => {
184
+ const user = resolveUser({ slackUserId: "U_RESOLVE_SLACK" });
185
+ expect(user).toBeDefined();
186
+ expect(user!.id).toBe(testUser.id);
187
+ });
188
+
189
+ test("resolves by linearUserId", () => {
190
+ const user = resolveUser({ linearUserId: "lin-resolve-uuid" });
191
+ expect(user).toBeDefined();
192
+ expect(user!.id).toBe(testUser.id);
193
+ });
194
+
195
+ test("resolves by githubUsername", () => {
196
+ const user = resolveUser({ githubUsername: "resolve-gh" });
197
+ expect(user).toBeDefined();
198
+ expect(user!.id).toBe(testUser.id);
199
+ });
200
+
201
+ test("resolves by gitlabUsername", () => {
202
+ const user = resolveUser({ gitlabUsername: "resolve-gl" });
203
+ expect(user).toBeDefined();
204
+ expect(user!.id).toBe(testUser.id);
205
+ });
206
+
207
+ test("resolves by primary email", () => {
208
+ const user = resolveUser({ email: "resolve-test@example.com" });
209
+ expect(user).toBeDefined();
210
+ expect(user!.id).toBe(testUser.id);
211
+ });
212
+
213
+ test("resolves by email alias (case-insensitive)", () => {
214
+ const user = resolveUser({ email: "RESOLVE-ALIAS@EXAMPLE.COM" });
215
+ expect(user).toBeDefined();
216
+ expect(user!.id).toBe(testUser.id);
217
+ });
218
+
219
+ test("resolves by name substring (case-insensitive)", () => {
220
+ const user = resolveUser({ name: "resolve testuser" });
221
+ expect(user).toBeDefined();
222
+ expect(user!.id).toBe(testUser.id);
223
+ });
224
+
225
+ test("returns null for no match", () => {
226
+ expect(resolveUser({ slackUserId: "U_NONEXISTENT" })).toBeNull();
227
+ expect(resolveUser({ email: "nobody@nowhere.com" })).toBeNull();
228
+ expect(resolveUser({ name: "ZZZNoSuchPerson" })).toBeNull();
229
+ });
230
+
231
+ test("prioritizes platform ID over email", () => {
232
+ // Create a second user with different slack ID
233
+ const user2 = createUser({
234
+ name: "PriorityUser",
235
+ slackUserId: "U_OTHER_PRIORITY",
236
+ });
237
+ // When resolving with both slackUserId and email, slackUserId wins
238
+ const resolved = resolveUser({
239
+ slackUserId: "U_OTHER_PRIORITY",
240
+ email: "resolve-test@example.com", // belongs to testUser
241
+ });
242
+ expect(resolved!.id).toBe(user2.id);
243
+ // Cleanup
244
+ deleteUser(user2.id);
245
+ });
246
+ });
247
+
248
+ // ─── requestedByUserId on tasks ─────────────────────────────────────────────
249
+
250
+ describe("requestedByUserId in tasks", () => {
251
+ test("createTaskExtended stores requestedByUserId", () => {
252
+ const user = createUser({ name: "Requester" });
253
+ const task = createTaskExtended("task with requester", {
254
+ agentId: workerAgent.id,
255
+ source: "slack",
256
+ requestedByUserId: user.id,
257
+ });
258
+ const fetched = getTaskById(task.id);
259
+ expect(fetched!.requestedByUserId).toBe(user.id);
260
+ deleteUser(user.id);
261
+ });
262
+
263
+ test("requestedByUserId inherits from parent task", () => {
264
+ const user = createUser({ name: "ParentRequester" });
265
+ const parent = createTaskExtended("parent task", {
266
+ agentId: leadAgent.id,
267
+ source: "slack",
268
+ requestedByUserId: user.id,
269
+ });
270
+ const child = createTaskExtended("child task", {
271
+ agentId: workerAgent.id,
272
+ source: "mcp",
273
+ parentTaskId: parent.id,
274
+ });
275
+ const fetchedChild = getTaskById(child.id);
276
+ expect(fetchedChild!.requestedByUserId).toBe(user.id);
277
+ deleteUser(user.id);
278
+ });
279
+
280
+ test("explicit requestedByUserId overrides parent inheritance", () => {
281
+ const user1 = createUser({ name: "User1" });
282
+ const user2 = createUser({ name: "User2" });
283
+ const parent = createTaskExtended("parent", {
284
+ agentId: leadAgent.id,
285
+ source: "slack",
286
+ requestedByUserId: user1.id,
287
+ });
288
+ const child = createTaskExtended("child", {
289
+ agentId: workerAgent.id,
290
+ source: "mcp",
291
+ parentTaskId: parent.id,
292
+ requestedByUserId: user2.id,
293
+ });
294
+ expect(getTaskById(child.id)!.requestedByUserId).toBe(user2.id);
295
+ deleteUser(user1.id);
296
+ deleteUser(user2.id);
297
+ });
298
+
299
+ test("task without requestedByUserId has undefined", () => {
300
+ const task = createTaskExtended("no user task", {
301
+ agentId: workerAgent.id,
302
+ source: "mcp",
303
+ });
304
+ expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
305
+ });
306
+ });