@desplega.ai/agent-swarm 1.69.0 → 1.70.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 (44) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +62 -1
  3. package/package.json +1 -1
  4. package/src/agentmail/handlers.ts +87 -6
  5. package/src/be/db.ts +34 -2
  6. package/src/be/migrations/042_task_context_key.sql +13 -0
  7. package/src/commands/runner.ts +1 -0
  8. package/src/github/handlers.ts +42 -10
  9. package/src/gitlab/handlers.ts +29 -5
  10. package/src/hooks/hook.ts +4 -2
  11. package/src/http/core.ts +36 -26
  12. package/src/http/mcp-oauth.ts +132 -60
  13. package/src/http/mcp-servers.ts +5 -1
  14. package/src/http/schedules.ts +4 -2
  15. package/src/http/tasks.ts +4 -2
  16. package/src/linear/sync.ts +22 -10
  17. package/src/providers/claude-adapter.ts +51 -29
  18. package/src/scheduler/scheduler.ts +9 -10
  19. package/src/server.ts +2 -0
  20. package/src/slack/actions.ts +10 -9
  21. package/src/slack/assistant.ts +8 -4
  22. package/src/slack/handlers.ts +8 -3
  23. package/src/slack/thread-buffer.ts +61 -72
  24. package/src/tasks/additive-buffer.ts +152 -0
  25. package/src/tasks/additive-ingress.ts +125 -0
  26. package/src/tasks/context-key.ts +245 -0
  27. package/src/tasks/sibling-awareness.ts +144 -0
  28. package/src/tasks/sibling-block.ts +164 -0
  29. package/src/tests/additive-buffer.test.ts +186 -0
  30. package/src/tests/additive-ingress.test.ts +111 -0
  31. package/src/tests/claude-adapter.test.ts +143 -1
  32. package/src/tests/context-key-db.test.ts +87 -0
  33. package/src/tests/context-key.test.ts +173 -0
  34. package/src/tests/core-auth.test.ts +142 -0
  35. package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
  36. package/src/tests/sibling-awareness-db.test.ts +172 -0
  37. package/src/tests/sibling-block.test.ts +232 -0
  38. package/src/tests/tool-annotations.test.ts +1 -0
  39. package/src/tools/slack-post.ts +10 -3
  40. package/src/tools/slack-start-thread.ts +123 -0
  41. package/src/tools/tool-config.ts +2 -1
  42. package/src/tools/update-profile.ts +5 -2
  43. package/src/types.ts +5 -0
  44. package/src/workflows/executors/agent-task.ts +21 -14
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ agentmailContextKey,
4
+ githubContextKey,
5
+ gitlabContextKey,
6
+ linearContextKey,
7
+ parseContextKey,
8
+ scheduleContextKey,
9
+ slackContextKey,
10
+ workflowContextKey,
11
+ } from "../tasks/context-key";
12
+
13
+ describe("context-key builders", () => {
14
+ test("slackContextKey builds expected format", () => {
15
+ expect(slackContextKey({ channelId: "C123", threadTs: "1776800534.257079" })).toBe(
16
+ "task:slack:C123:1776800534.257079",
17
+ );
18
+ });
19
+
20
+ test("agentmailContextKey builds expected format", () => {
21
+ expect(agentmailContextKey({ threadId: "thr_abc" })).toBe("task:agentmail:thr_abc");
22
+ });
23
+
24
+ test("githubContextKey preserves owner/repo case", () => {
25
+ expect(
26
+ githubContextKey({ owner: "Desplega-AI", repo: "Agent-Swarm", kind: "pr", number: 42 }),
27
+ ).toBe("task:trackers:github:Desplega-AI:Agent-Swarm:pr:42");
28
+ });
29
+
30
+ test("gitlabContextKey accepts numeric project id and stringifies it", () => {
31
+ expect(gitlabContextKey({ projectId: 789, kind: "mr", iid: 12 })).toBe(
32
+ "task:trackers:gitlab:789:mr:12",
33
+ );
34
+ });
35
+
36
+ test("linearContextKey preserves identifier case", () => {
37
+ expect(linearContextKey({ issueIdentifier: "DES-42" })).toBe("task:trackers:linear:DES-42");
38
+ });
39
+
40
+ test("scheduleContextKey builds expected format", () => {
41
+ expect(scheduleContextKey({ scheduleId: "sched-uuid" })).toBe("task:schedule:sched-uuid");
42
+ });
43
+
44
+ test("workflowContextKey builds expected format", () => {
45
+ expect(workflowContextKey({ workflowRunId: "run-uuid" })).toBe("task:workflow:run-uuid");
46
+ });
47
+ });
48
+
49
+ describe("context-key separator safety", () => {
50
+ test("slackContextKey throws when channelId contains ':'", () => {
51
+ expect(() => slackContextKey({ channelId: "C:123", threadTs: "1" })).toThrow(
52
+ /must not contain/,
53
+ );
54
+ });
55
+
56
+ test("slackContextKey throws when threadTs contains ':'", () => {
57
+ expect(() => slackContextKey({ channelId: "C1", threadTs: "a:b" })).toThrow(/must not contain/);
58
+ });
59
+
60
+ test("githubContextKey throws when repo contains ':'", () => {
61
+ expect(() =>
62
+ githubContextKey({ owner: "desplega", repo: "bad:repo", kind: "pr", number: 1 }),
63
+ ).toThrow(/must not contain/);
64
+ });
65
+
66
+ test("agentmailContextKey throws when threadId contains ':'", () => {
67
+ expect(() => agentmailContextKey({ threadId: "thr:bad" })).toThrow(/must not contain/);
68
+ });
69
+
70
+ test("linearContextKey throws when identifier contains ':'", () => {
71
+ expect(() => linearContextKey({ issueIdentifier: "DES:42" })).toThrow(/must not contain/);
72
+ });
73
+
74
+ test("throws on empty values", () => {
75
+ expect(() => slackContextKey({ channelId: "", threadTs: "1" })).toThrow(/non-empty/);
76
+ expect(() => agentmailContextKey({ threadId: "" })).toThrow(/non-empty/);
77
+ expect(() => linearContextKey({ issueIdentifier: "" })).toThrow(/non-empty/);
78
+ });
79
+
80
+ test("githubContextKey validates kind", () => {
81
+ // @ts-expect-error — testing runtime guard
82
+ expect(() => githubContextKey({ owner: "a", repo: "b", kind: "bogus", number: 1 })).toThrow(
83
+ /kind/,
84
+ );
85
+ });
86
+
87
+ test("gitlabContextKey validates kind", () => {
88
+ // @ts-expect-error — testing runtime guard
89
+ expect(() => gitlabContextKey({ projectId: "1", kind: "bogus", iid: 1 })).toThrow(/kind/);
90
+ });
91
+
92
+ test("githubContextKey rejects non-numeric number", () => {
93
+ // @ts-expect-error — testing runtime guard
94
+ expect(() => githubContextKey({ owner: "a", repo: "b", kind: "pr", number: "abc" })).toThrow(
95
+ /integer/,
96
+ );
97
+ });
98
+ });
99
+
100
+ describe("parseContextKey", () => {
101
+ test("round-trips slack keys", () => {
102
+ const key = slackContextKey({ channelId: "C0A4J7GB0UD", threadTs: "1776800534.257079" });
103
+ const parsed = parseContextKey(key);
104
+ expect(parsed).toEqual({
105
+ family: "slack",
106
+ parts: { channelId: "C0A4J7GB0UD", threadTs: "1776800534.257079" },
107
+ });
108
+ });
109
+
110
+ test("round-trips agentmail keys", () => {
111
+ const key = agentmailContextKey({ threadId: "thr_abc" });
112
+ expect(parseContextKey(key)).toEqual({
113
+ family: "agentmail",
114
+ parts: { threadId: "thr_abc" },
115
+ });
116
+ });
117
+
118
+ test("round-trips github keys and preserves case", () => {
119
+ const key = githubContextKey({
120
+ owner: "Desplega-AI",
121
+ repo: "Agent-Swarm",
122
+ kind: "issue",
123
+ number: 123,
124
+ });
125
+ expect(parseContextKey(key)).toEqual({
126
+ family: "trackers",
127
+ subFamily: "github",
128
+ parts: { owner: "Desplega-AI", repo: "Agent-Swarm", kind: "issue", number: 123 },
129
+ });
130
+ });
131
+
132
+ test("round-trips gitlab keys", () => {
133
+ const key = gitlabContextKey({ projectId: 789, kind: "mr", iid: 7 });
134
+ expect(parseContextKey(key)).toEqual({
135
+ family: "trackers",
136
+ subFamily: "gitlab",
137
+ parts: { projectId: "789", kind: "mr", iid: 7 },
138
+ });
139
+ });
140
+
141
+ test("round-trips linear keys with case preserved", () => {
142
+ const key = linearContextKey({ issueIdentifier: "DES-42" });
143
+ expect(parseContextKey(key)).toEqual({
144
+ family: "trackers",
145
+ subFamily: "linear",
146
+ parts: { issueIdentifier: "DES-42" },
147
+ });
148
+ });
149
+
150
+ test("round-trips schedule keys", () => {
151
+ const key = scheduleContextKey({ scheduleId: "sched-1" });
152
+ expect(parseContextKey(key)).toEqual({
153
+ family: "schedule",
154
+ parts: { scheduleId: "sched-1" },
155
+ });
156
+ });
157
+
158
+ test("round-trips workflow keys", () => {
159
+ const key = workflowContextKey({ workflowRunId: "run-1" });
160
+ expect(parseContextKey(key)).toEqual({
161
+ family: "workflow",
162
+ parts: { workflowRunId: "run-1" },
163
+ });
164
+ });
165
+
166
+ test("throws on malformed keys", () => {
167
+ expect(() => parseContextKey("")).toThrow();
168
+ expect(() => parseContextKey("not-a-task-key")).toThrow();
169
+ expect(() => parseContextKey("task:slack:C1")).toThrow(/slack/);
170
+ expect(() => parseContextKey("task:trackers:bogus:x")).toThrow(/sub-family/);
171
+ expect(() => parseContextKey("task:trackers:github:a:b:weird:1")).toThrow(/kind/);
172
+ });
173
+ });
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ createServer as createHttpServer,
4
+ type IncomingMessage,
5
+ type Server,
6
+ type ServerResponse,
7
+ } from "node:http";
8
+ import { handleCore } from "../http/core";
9
+ // Importing the handlers here is load-bearing: each import populates
10
+ // `routeRegistry` as a side effect via the `route()` factory, which is what
11
+ // the auth middleware consults.
12
+ import "../http/webhooks";
13
+ import "../http/mcp-oauth";
14
+ import "../http/trackers/linear";
15
+ import "../http/workflows";
16
+
17
+ const API_KEY = "test-secret-key";
18
+
19
+ function createTestServer(apiKey: string): Server {
20
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
21
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
22
+ const handled = await handleCore(req, res, myAgentId, apiKey);
23
+ if (!handled) {
24
+ res.writeHead(200, { "Content-Type": "application/json" });
25
+ res.end(JSON.stringify({ passed: true }));
26
+ }
27
+ });
28
+ }
29
+
30
+ async function listen(server: Server): Promise<number> {
31
+ await new Promise<void>((resolve) => server.listen(0, resolve));
32
+ const addr = server.address();
33
+ if (!addr || typeof addr === "string") throw new Error("no port");
34
+ return addr.port;
35
+ }
36
+
37
+ describe("handleCore auth middleware (route() auth.apiKey=false is honored)", () => {
38
+ let server: Server;
39
+ let port: number;
40
+
41
+ beforeAll(async () => {
42
+ server = createTestServer(API_KEY);
43
+ port = await listen(server);
44
+ });
45
+
46
+ afterAll(() => {
47
+ server.close();
48
+ });
49
+
50
+ test("public route (auth:{apiKey:false}) passes without a Bearer", async () => {
51
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/callback`);
52
+ // The callback route is public — without a state param it returns 400
53
+ // from the handler, but it must NOT be 401 from the auth middleware.
54
+ expect(res.status).not.toBe(401);
55
+ });
56
+
57
+ test("authed route (no auth flag → default authed) returns 401 without Bearer", async () => {
58
+ // /api/mcp-oauth/<id>/status is declared with auth:{apiKey:true}
59
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
60
+ expect(res.status).toBe(401);
61
+ const body = await res.json();
62
+ expect(body.error).toBe("Unauthorized");
63
+ });
64
+
65
+ test("authed route passes with correct Bearer", async () => {
66
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`, {
67
+ headers: { Authorization: `Bearer ${API_KEY}` },
68
+ });
69
+ // Middleware passes (no 401). Downstream handler decides the final status.
70
+ expect(res.status).not.toBe(401);
71
+ });
72
+
73
+ test("authed route returns 401 with wrong Bearer", async () => {
74
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`, {
75
+ headers: { Authorization: "Bearer WRONG" },
76
+ });
77
+ expect(res.status).toBe(401);
78
+ });
79
+
80
+ test("GitHub webhook is still public (auth:{apiKey:false})", async () => {
81
+ const res = await fetch(`http://localhost:${port}/api/github/webhook`, { method: "POST" });
82
+ expect(res.status).not.toBe(401);
83
+ });
84
+
85
+ test("GitLab webhook is still public", async () => {
86
+ const res = await fetch(`http://localhost:${port}/api/gitlab/webhook`, { method: "POST" });
87
+ expect(res.status).not.toBe(401);
88
+ });
89
+
90
+ test("AgentMail webhook is still public", async () => {
91
+ const res = await fetch(`http://localhost:${port}/api/agentmail/webhook`, { method: "POST" });
92
+ expect(res.status).not.toBe(401);
93
+ });
94
+
95
+ test("Linear webhook/authorize/callback are still public", async () => {
96
+ for (const path of [
97
+ "/api/trackers/linear/authorize",
98
+ "/api/trackers/linear/callback",
99
+ "/api/trackers/linear/webhook",
100
+ ]) {
101
+ const method = path.endsWith("/webhook") ? "POST" : "GET";
102
+ const res = await fetch(`http://localhost:${port}${path}`, { method });
103
+ expect(res.status).not.toBe(401);
104
+ }
105
+ });
106
+
107
+ test("Workflow webhook trigger is still public", async () => {
108
+ const res = await fetch(`http://localhost:${port}/api/webhooks/some-workflow-id`, {
109
+ method: "POST",
110
+ });
111
+ expect(res.status).not.toBe(401);
112
+ });
113
+
114
+ test("unknown /api/* path fails closed (401 without Bearer)", async () => {
115
+ const res = await fetch(`http://localhost:${port}/api/does-not-exist/xyz`);
116
+ expect(res.status).toBe(401);
117
+ });
118
+
119
+ test("/health is always public", async () => {
120
+ const res = await fetch(`http://localhost:${port}/health`);
121
+ expect(res.status).toBe(200);
122
+ });
123
+ });
124
+
125
+ describe("handleCore auth middleware (no API_KEY configured)", () => {
126
+ let server: Server;
127
+ let port: number;
128
+
129
+ beforeAll(async () => {
130
+ server = createTestServer(""); // empty == auth disabled
131
+ port = await listen(server);
132
+ });
133
+
134
+ afterAll(() => {
135
+ server.close();
136
+ });
137
+
138
+ test("authed routes pass without Bearer when API_KEY is empty", async () => {
139
+ const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
140
+ expect(res.status).not.toBe(401);
141
+ });
142
+ });
@@ -101,6 +101,85 @@ describe("resolveSecrets integration — OAuth Authorization injection", () => {
101
101
  expect(match!.authError).toBeNull();
102
102
  });
103
103
 
104
+ test("lowercase 'bearer' tokenType is normalized to capital 'Bearer' in Authorization header", async () => {
105
+ // Providers that follow RFC 6749 strictly (e.g. Amplitude's MCP) return
106
+ // `token_type: "bearer"`. Some resource servers then reject the lowercase
107
+ // prefix with 401. The fix in src/http/mcp-servers.ts normalizes the
108
+ // scheme to capital "Bearer". See GitHub issue #368.
109
+ const agent = createAgent({
110
+ id: crypto.randomUUID(),
111
+ name: "oauth-agent-lowercase",
112
+ status: "idle",
113
+ isLead: false,
114
+ });
115
+ const mcp = createMcpServer({
116
+ name: "mcp-oauth-lowercase-bearer",
117
+ transport: "http",
118
+ url: "https://mcp.example.com",
119
+ scope: "agent",
120
+ ownerAgentId: agent.id,
121
+ });
122
+ installMcpServer(agent.id, mcp.id);
123
+ setMcpServerAuthMethod(mcp.id, "oauth");
124
+ upsertMcpOAuthToken({
125
+ mcpServerId: mcp.id,
126
+ accessToken: "lowercase-token-xyz",
127
+ refreshToken: null,
128
+ tokenType: "bearer", // lowercase, as RFC 6749 prescribes
129
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
130
+ resourceUrl: "https://mcp.example.com/",
131
+ authorizationServerIssuer: "https://as.example.com",
132
+ authorizeUrl: "https://as.example.com/authorize",
133
+ tokenUrl: "https://as.example.com/token",
134
+ clientSource: "dcr",
135
+ status: "connected",
136
+ });
137
+
138
+ const result = await agentMcpServers(agent.id);
139
+ const match = result.servers.find((s) => s.id === mcp.id);
140
+ expect(match).toBeTruthy();
141
+ expect(match!.resolvedHeaders?.Authorization).toBe("Bearer lowercase-token-xyz");
142
+ expect(match!.resolvedHeaders?.Authorization?.startsWith("Bearer ")).toBe(true);
143
+ });
144
+
145
+ test("non-bearer tokenType (e.g. 'MAC') is preserved verbatim in Authorization header", async () => {
146
+ // RFC 6749 allows non-bearer token types. The normalization must only
147
+ // touch the bearer scheme and leave others alone.
148
+ const agent = createAgent({
149
+ id: crypto.randomUUID(),
150
+ name: "oauth-agent-mac",
151
+ status: "idle",
152
+ isLead: false,
153
+ });
154
+ const mcp = createMcpServer({
155
+ name: "mcp-oauth-mac",
156
+ transport: "http",
157
+ url: "https://mcp.example.com",
158
+ scope: "agent",
159
+ ownerAgentId: agent.id,
160
+ });
161
+ installMcpServer(agent.id, mcp.id);
162
+ setMcpServerAuthMethod(mcp.id, "oauth");
163
+ upsertMcpOAuthToken({
164
+ mcpServerId: mcp.id,
165
+ accessToken: "mac-token-xyz",
166
+ refreshToken: null,
167
+ tokenType: "MAC",
168
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
169
+ resourceUrl: "https://mcp.example.com/",
170
+ authorizationServerIssuer: "https://as.example.com",
171
+ authorizeUrl: "https://as.example.com/authorize",
172
+ tokenUrl: "https://as.example.com/token",
173
+ clientSource: "dcr",
174
+ status: "connected",
175
+ });
176
+
177
+ const result = await agentMcpServers(agent.id);
178
+ const match = result.servers.find((s) => s.id === mcp.id);
179
+ expect(match).toBeTruthy();
180
+ expect(match!.resolvedHeaders?.Authorization).toBe("MAC mac-token-xyz");
181
+ });
182
+
104
183
  test("OAuth server without token row surfaces authError", async () => {
105
184
  const agent = createAgent({
106
185
  id: crypto.randomUUID(),
@@ -0,0 +1,172 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import { closeDb, completeTask, createAgent, createTaskExtended, initDb } from "../be/db";
4
+ import { slackContextKey } from "../tasks/context-key";
5
+ import { applySiblingAwareness } from "../tasks/sibling-awareness";
6
+
7
+ const TEST_DB_PATH = "./test-sibling-awareness-db.sqlite";
8
+
9
+ beforeAll(() => {
10
+ initDb(TEST_DB_PATH);
11
+ });
12
+
13
+ afterAll(() => {
14
+ closeDb();
15
+ try {
16
+ unlinkSync(TEST_DB_PATH);
17
+ unlinkSync(`${TEST_DB_PATH}-wal`);
18
+ unlinkSync(`${TEST_DB_PATH}-shm`);
19
+ } catch {
20
+ // ignore
21
+ }
22
+ });
23
+
24
+ describe("applySiblingAwareness — no siblings", () => {
25
+ test("returns description unchanged when no siblings exist", () => {
26
+ const key = slackContextKey({
27
+ channelId: "C_SIB_NONE",
28
+ threadTs: "1700000000.000001",
29
+ });
30
+ const out = applySiblingAwareness({ description: "Do the thing", contextKey: key });
31
+ expect(out.description).toBe("Do the thing");
32
+ expect(out.parentTaskId).toBeUndefined();
33
+ expect(out.siblings).toEqual([]);
34
+ });
35
+
36
+ test("returns description unchanged when contextKey is empty", () => {
37
+ const out = applySiblingAwareness({ description: "body", contextKey: "" });
38
+ expect(out.description).toBe("body");
39
+ expect(out.parentTaskId).toBeUndefined();
40
+ expect(out.siblings).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe("applySiblingAwareness — with siblings", () => {
45
+ test("prepends sibling block when an in-flight sibling exists", () => {
46
+ const agent = createAgent({
47
+ name: "sib-agent-1",
48
+ isLead: false,
49
+ status: "idle",
50
+ capabilities: [],
51
+ });
52
+ const key = slackContextKey({
53
+ channelId: "C_SIB_1",
54
+ threadTs: "1700000000.000002",
55
+ });
56
+ const existing = createTaskExtended("First task that the user already sent", {
57
+ agentId: agent.id,
58
+ contextKey: key,
59
+ });
60
+
61
+ const result = applySiblingAwareness({
62
+ description: "Follow-up body",
63
+ contextKey: key,
64
+ currentAgentId: agent.id,
65
+ });
66
+
67
+ expect(result.description).toContain("<sibling_tasks_in_progress>");
68
+ expect(result.description).toContain(`contextKey: ${key}`);
69
+ expect(result.description).toContain(`task:${existing.id}`);
70
+ expect(result.description).toContain(`agent:${agent.name}`);
71
+ expect(result.description.endsWith("Follow-up body")).toBe(true);
72
+ expect(result.siblings.map((s) => s.id)).toContain(existing.id);
73
+ });
74
+
75
+ test("auto-wires parentTaskId when sibling is on the same agent", () => {
76
+ const agent = createAgent({
77
+ name: "sib-agent-2",
78
+ isLead: false,
79
+ status: "idle",
80
+ capabilities: [],
81
+ });
82
+ const key = slackContextKey({
83
+ channelId: "C_SIB_2",
84
+ threadTs: "1700000000.000003",
85
+ });
86
+ const existing = createTaskExtended("Original", {
87
+ agentId: agent.id,
88
+ contextKey: key,
89
+ });
90
+
91
+ const result = applySiblingAwareness({
92
+ description: "Follow-up",
93
+ contextKey: key,
94
+ currentAgentId: agent.id,
95
+ });
96
+ expect(result.parentTaskId).toBe(existing.id);
97
+ });
98
+
99
+ test("does NOT auto-wire parentTaskId when sibling is on a different agent", () => {
100
+ const agentA = createAgent({
101
+ name: "sib-agent-3A",
102
+ isLead: false,
103
+ status: "idle",
104
+ capabilities: [],
105
+ });
106
+ const agentB = createAgent({
107
+ name: "sib-agent-3B",
108
+ isLead: false,
109
+ status: "idle",
110
+ capabilities: [],
111
+ });
112
+ const key = slackContextKey({
113
+ channelId: "C_SIB_3",
114
+ threadTs: "1700000000.000004",
115
+ });
116
+ createTaskExtended("Task on A", { agentId: agentA.id, contextKey: key });
117
+
118
+ // New task is destined for agentB — no resume wiring.
119
+ const result = applySiblingAwareness({
120
+ description: "Body",
121
+ contextKey: key,
122
+ currentAgentId: agentB.id,
123
+ });
124
+ expect(result.parentTaskId).toBeUndefined();
125
+ // But the sibling block is still included so agentB sees what's in flight.
126
+ expect(result.description).toContain("<sibling_tasks_in_progress>");
127
+ expect(result.description).toContain(`agent:${agentA.name}`);
128
+ });
129
+
130
+ test("does NOT auto-wire parentTaskId when currentAgentId is undefined", () => {
131
+ const agent = createAgent({
132
+ name: "sib-agent-4",
133
+ isLead: false,
134
+ status: "idle",
135
+ capabilities: [],
136
+ });
137
+ const key = slackContextKey({
138
+ channelId: "C_SIB_4",
139
+ threadTs: "1700000000.000005",
140
+ });
141
+ createTaskExtended("Existing", { agentId: agent.id, contextKey: key });
142
+
143
+ const result = applySiblingAwareness({ description: "Body", contextKey: key });
144
+ expect(result.parentTaskId).toBeUndefined();
145
+ // Block still included — useful for the worker that eventually picks it up.
146
+ expect(result.description).toContain("<sibling_tasks_in_progress>");
147
+ });
148
+
149
+ test("excludes terminal tasks (completed) from sibling results", () => {
150
+ const agent = createAgent({
151
+ name: "sib-agent-5",
152
+ isLead: false,
153
+ status: "idle",
154
+ capabilities: [],
155
+ });
156
+ const key = slackContextKey({
157
+ channelId: "C_SIB_5",
158
+ threadTs: "1700000000.000006",
159
+ });
160
+ const done = createTaskExtended("Done", { agentId: agent.id, contextKey: key });
161
+ completeTask(done.id, "ok");
162
+
163
+ const result = applySiblingAwareness({
164
+ description: "Body",
165
+ contextKey: key,
166
+ currentAgentId: agent.id,
167
+ });
168
+ expect(result.siblings).toEqual([]);
169
+ expect(result.description).toBe("Body");
170
+ expect(result.parentTaskId).toBeUndefined();
171
+ });
172
+ });