@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,186 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createAdditiveBuffer } from "../tasks/additive-buffer";
3
+
4
+ /**
5
+ * Helper: wait for a timer-based flush. We use tight timeouts (10-30ms) so
6
+ * tests stay fast — these operate on Bun's event loop, not real time.
7
+ */
8
+ function sleep(ms: number): Promise<void> {
9
+ return new Promise((r) => setTimeout(r, ms));
10
+ }
11
+
12
+ describe("createAdditiveBuffer", () => {
13
+ test("rejects non-positive timeoutMs", () => {
14
+ expect(() => createAdditiveBuffer({ timeoutMs: 0, onFlush: () => {} })).toThrow(
15
+ /positive number/,
16
+ );
17
+ expect(() => createAdditiveBuffer({ timeoutMs: -1, onFlush: () => {} })).toThrow();
18
+ // biome-ignore lint/suspicious/noExplicitAny: type-guard test
19
+ expect(() => createAdditiveBuffer({ timeoutMs: NaN as any, onFlush: () => {} })).toThrow();
20
+ });
21
+
22
+ test("enqueue without existing buffer creates one with timer", () => {
23
+ let flushed: number[] | null = null;
24
+ const buf = createAdditiveBuffer<number>({
25
+ timeoutMs: 10_000,
26
+ onFlush: (items) => {
27
+ flushed = [...items];
28
+ },
29
+ });
30
+ buf.enqueue("k1", 1);
31
+ expect(buf.isBuffered("k1")).toBe(true);
32
+ expect(buf.count("k1")).toBe(1);
33
+ expect(flushed).toBeNull();
34
+ buf.cancel("k1");
35
+ });
36
+
37
+ test("three rapid enqueues coalesce into one flush", async () => {
38
+ const flushes: string[][] = [];
39
+ const buf = createAdditiveBuffer<string>({
40
+ timeoutMs: 20,
41
+ onFlush: (items) => {
42
+ flushes.push([...items]);
43
+ },
44
+ label: "test-coalesce",
45
+ });
46
+
47
+ buf.enqueue("k", "a");
48
+ await sleep(5);
49
+ buf.enqueue("k", "b");
50
+ await sleep(5);
51
+ buf.enqueue("k", "c");
52
+ expect(buf.count("k")).toBe(3);
53
+
54
+ // Wait for debounce to elapse
55
+ await sleep(50);
56
+
57
+ expect(flushes.length).toBe(1);
58
+ expect(flushes[0]).toEqual(["a", "b", "c"]);
59
+ expect(buf.isBuffered("k")).toBe(false);
60
+ });
61
+
62
+ test("enqueue resets the timer", async () => {
63
+ const flushes: number[][] = [];
64
+ const buf = createAdditiveBuffer<number>({
65
+ timeoutMs: 30,
66
+ onFlush: (items) => {
67
+ flushes.push([...items]);
68
+ },
69
+ });
70
+
71
+ buf.enqueue("k", 1);
72
+ await sleep(20); // 20 < 30, timer would have NOT fired
73
+ buf.enqueue("k", 2); // resets
74
+ await sleep(20); // another 20 < 30
75
+ buf.enqueue("k", 3); // resets again
76
+
77
+ expect(flushes.length).toBe(0);
78
+ await sleep(60);
79
+ expect(flushes.length).toBe(1);
80
+ expect(flushes[0]).toEqual([1, 2, 3]);
81
+ });
82
+
83
+ test("instantFlush fires immediately with reason='manual'", async () => {
84
+ let seenReason: string | null = null;
85
+ const buf = createAdditiveBuffer<number>({
86
+ timeoutMs: 10_000,
87
+ onFlush: (_items, _key, reason) => {
88
+ seenReason = reason;
89
+ },
90
+ });
91
+ buf.enqueue("k", 1);
92
+ await buf.instantFlush("k");
93
+ expect(seenReason).toBe("manual");
94
+ expect(buf.isBuffered("k")).toBe(false);
95
+ });
96
+
97
+ test("timer flush reports reason='timer'", async () => {
98
+ let seenReason: string | null = null;
99
+ const buf = createAdditiveBuffer<number>({
100
+ timeoutMs: 10,
101
+ onFlush: (_items, _key, reason) => {
102
+ seenReason = reason;
103
+ },
104
+ });
105
+ buf.enqueue("k", 1);
106
+ await sleep(40);
107
+ expect(seenReason).toBe("timer");
108
+ });
109
+
110
+ test("instantFlush on unknown key is a no-op", async () => {
111
+ let called = false;
112
+ const buf = createAdditiveBuffer<number>({
113
+ timeoutMs: 10_000,
114
+ onFlush: () => {
115
+ called = true;
116
+ },
117
+ });
118
+ await buf.instantFlush("nope");
119
+ expect(called).toBe(false);
120
+ });
121
+
122
+ test("cancel drops the buffer without flushing", async () => {
123
+ let called = false;
124
+ const buf = createAdditiveBuffer<number>({
125
+ timeoutMs: 20,
126
+ onFlush: () => {
127
+ called = true;
128
+ },
129
+ });
130
+ buf.enqueue("k", 1);
131
+ expect(buf.cancel("k")).toBe(true);
132
+ expect(buf.isBuffered("k")).toBe(false);
133
+ await sleep(50);
134
+ expect(called).toBe(false);
135
+ });
136
+
137
+ test("cancel on unknown key returns false", () => {
138
+ const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
139
+ expect(buf.cancel("nope")).toBe(false);
140
+ });
141
+
142
+ test("enqueue rejects empty contextKey", () => {
143
+ const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
144
+ expect(() => buf.enqueue("", 1)).toThrow(/contextKey/);
145
+ });
146
+
147
+ test("onFlush errors are swallowed (logged, buffer still clears)", async () => {
148
+ const buf = createAdditiveBuffer<number>({
149
+ timeoutMs: 10,
150
+ onFlush: () => {
151
+ throw new Error("boom");
152
+ },
153
+ });
154
+ buf.enqueue("k", 1);
155
+ await sleep(40);
156
+ expect(buf.isBuffered("k")).toBe(false);
157
+ });
158
+
159
+ test("buffers are independent across keys", async () => {
160
+ const flushes: Record<string, number[][]> = {};
161
+ const buf = createAdditiveBuffer<number>({
162
+ timeoutMs: 20,
163
+ onFlush: (items, key) => {
164
+ const arr = flushes[key] ?? [];
165
+ arr.push([...items]);
166
+ flushes[key] = arr;
167
+ },
168
+ });
169
+ buf.enqueue("a", 1);
170
+ buf.enqueue("b", 10);
171
+ buf.enqueue("a", 2);
172
+ await sleep(50);
173
+ expect(flushes.a).toEqual([[1, 2]]);
174
+ expect(flushes.b).toEqual([[10]]);
175
+ });
176
+
177
+ test("keys() returns active buffer keys", () => {
178
+ const buf = createAdditiveBuffer<number>({ timeoutMs: 10_000, onFlush: () => {} });
179
+ buf.enqueue("a", 1);
180
+ buf.enqueue("b", 2);
181
+ expect(new Set(buf.keys())).toEqual(new Set(["a", "b"]));
182
+ buf.cancel("a");
183
+ expect(buf.keys()).toEqual(["b"]);
184
+ buf.cancel("b");
185
+ });
186
+ });
@@ -0,0 +1,111 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createIngressBuffer } from "../tasks/additive-ingress";
3
+
4
+ function sleep(ms: number): Promise<void> {
5
+ return new Promise((r) => setTimeout(r, ms));
6
+ }
7
+
8
+ const FLAG = "TEST_ADDITIVE_INGRESS";
9
+
10
+ describe("createIngressBuffer", () => {
11
+ beforeEach(() => {
12
+ process.env[FLAG] = "true";
13
+ });
14
+ afterEach(() => {
15
+ process.env[FLAG] = undefined;
16
+ });
17
+
18
+ test("env flag off → maybeBuffer always returns false", () => {
19
+ process.env[FLAG] = "false";
20
+ const flushes: string[][] = [];
21
+ const ib = createIngressBuffer<string>({
22
+ source: "test",
23
+ envFlag: FLAG,
24
+ timeoutMs: 20,
25
+ onFlush: (items) => {
26
+ flushes.push([...items]);
27
+ },
28
+ });
29
+ expect(ib.enabled).toBe(false);
30
+ expect(ib.maybeBuffer("ctx", true, "a")).toBe(false);
31
+ expect(ib.isBuffered("ctx")).toBe(false);
32
+ });
33
+
34
+ test("no sibling in flight → maybeBuffer returns false", () => {
35
+ const ib = createIngressBuffer<string>({
36
+ source: "test",
37
+ envFlag: FLAG,
38
+ timeoutMs: 20,
39
+ onFlush: () => {},
40
+ });
41
+ expect(ib.enabled).toBe(true);
42
+ expect(ib.maybeBuffer("ctx", false, "a")).toBe(false);
43
+ });
44
+
45
+ test("empty contextKey → maybeBuffer returns false", () => {
46
+ const ib = createIngressBuffer<string>({
47
+ source: "test",
48
+ envFlag: FLAG,
49
+ timeoutMs: 20,
50
+ onFlush: () => {},
51
+ });
52
+ expect(ib.maybeBuffer("", true, "a")).toBe(false);
53
+ });
54
+
55
+ test("enabled + sibling in flight + contextKey → buffers", async () => {
56
+ const flushes: Array<{ items: string[]; key: string; reason: string }> = [];
57
+ const ib = createIngressBuffer<string>({
58
+ source: "test",
59
+ envFlag: FLAG,
60
+ timeoutMs: 20,
61
+ onFlush: (items, key, reason) => {
62
+ flushes.push({ items: [...items], key, reason });
63
+ },
64
+ });
65
+ expect(ib.maybeBuffer("ctx", true, "one")).toBe(true);
66
+ expect(ib.maybeBuffer("ctx", true, "two")).toBe(true);
67
+ expect(ib.maybeBuffer("ctx", true, "three")).toBe(true);
68
+ expect(ib.count("ctx")).toBe(3);
69
+
70
+ await sleep(80);
71
+ expect(flushes.length).toBe(1);
72
+ expect(flushes[0]?.items).toEqual(["one", "two", "three"]);
73
+ expect(flushes[0]?.key).toBe("ctx");
74
+ expect(flushes[0]?.reason).toBe("timer");
75
+ });
76
+
77
+ test("instantFlush resolves immediately with reason=manual", async () => {
78
+ const flushes: Array<{ items: string[]; reason: string }> = [];
79
+ const ib = createIngressBuffer<string>({
80
+ source: "test",
81
+ envFlag: FLAG,
82
+ timeoutMs: 5000, // long
83
+ onFlush: (items, _key, reason) => {
84
+ flushes.push({ items: [...items], reason });
85
+ },
86
+ });
87
+ ib.maybeBuffer("k", true, "x");
88
+ ib.maybeBuffer("k", true, "y");
89
+ await ib.instantFlush("k");
90
+ expect(flushes.length).toBe(1);
91
+ expect(flushes[0]?.items).toEqual(["x", "y"]);
92
+ expect(flushes[0]?.reason).toBe("manual");
93
+ });
94
+
95
+ test("cancel drops items without flushing", async () => {
96
+ const flushes: string[][] = [];
97
+ const ib = createIngressBuffer<string>({
98
+ source: "test",
99
+ envFlag: FLAG,
100
+ timeoutMs: 20,
101
+ onFlush: (items) => {
102
+ flushes.push([...items]);
103
+ },
104
+ });
105
+ ib.maybeBuffer("k", true, "a");
106
+ ib.cancel("k");
107
+ await sleep(50);
108
+ expect(flushes.length).toBe(0);
109
+ expect(ib.isBuffered("k")).toBe(false);
110
+ });
111
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { ClaudeAdapter } from "../providers/claude-adapter";
2
+ import { ClaudeAdapter, mergeMcpConfig } from "../providers/claude-adapter";
3
3
  import type { ProviderSessionConfig } from "../providers/types";
4
4
 
5
5
  /** Minimal config for testing — sessions won't actually spawn in these unit tests */
@@ -103,6 +103,148 @@ describe("Claude stream-json event parsing", () => {
103
103
  });
104
104
  });
105
105
 
106
+ describe("mergeMcpConfig (issue #369)", () => {
107
+ const TASK_ID = "task-abc-123";
108
+
109
+ test("returns only installed servers when base config is null", () => {
110
+ const installed = {
111
+ "my-mcp": {
112
+ type: "http",
113
+ url: "https://example.com",
114
+ headers: { Authorization: "Bearer x" },
115
+ },
116
+ };
117
+ const merged = mergeMcpConfig(null, installed, TASK_ID);
118
+ expect(merged.mcpServers["my-mcp"]).toEqual(installed["my-mcp"]);
119
+ });
120
+
121
+ test("returns only base servers when installedServers is null", () => {
122
+ const base = {
123
+ mcpServers: {
124
+ "agent-swarm": {
125
+ type: "http",
126
+ url: "http://localhost:3013/mcp",
127
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
128
+ },
129
+ },
130
+ };
131
+ const merged = mergeMcpConfig(base, null, TASK_ID);
132
+ const agentSwarm = merged.mcpServers["agent-swarm"] as Record<string, unknown>;
133
+ expect(agentSwarm).toBeDefined();
134
+ // Agent-swarm entry is augmented with X-Source-Task-Id
135
+ expect((agentSwarm.headers as Record<string, string>)["X-Source-Task-Id"]).toBe(TASK_ID);
136
+ });
137
+
138
+ test("installed servers OVERRIDE stale .mcp.json entries (precedence fix)", () => {
139
+ // Simulates: /workspace/.mcp.json has an entry baked at container startup with
140
+ // a stale OAuth Bearer; the per-session fetch returns a freshly-resolved Bearer.
141
+ // The merged config MUST carry the fresh token — this is the core of issue #369.
142
+ const base = {
143
+ mcpServers: {
144
+ stripe: {
145
+ type: "http",
146
+ url: "https://mcp.stripe.com",
147
+ headers: { Authorization: "Bearer STALE_TOKEN_FROM_STARTUP" },
148
+ },
149
+ },
150
+ };
151
+ const installed = {
152
+ stripe: {
153
+ type: "http",
154
+ url: "https://mcp.stripe.com",
155
+ headers: { Authorization: "Bearer FRESH_TOKEN_FROM_API" },
156
+ },
157
+ };
158
+ const merged = mergeMcpConfig(base, installed, TASK_ID);
159
+ const stripe = merged.mcpServers.stripe as Record<string, unknown>;
160
+ expect((stripe.headers as Record<string, string>).Authorization).toBe(
161
+ "Bearer FRESH_TOKEN_FROM_API",
162
+ );
163
+ });
164
+
165
+ test("installed-server removal is honored (uninstall propagates)", () => {
166
+ // Previously, if .mcp.json had `stripe` baked in but the server was uninstalled
167
+ // from the API, the stale entry persisted. With the precedence fix + skeleton
168
+ // .mcp.json, a server absent from installedServers stays in the merged config
169
+ // ONLY if it's also in base (e.g., manually-added) — no API-layer override is
170
+ // issued. This test confirms we don't spontaneously delete base entries; the
171
+ // docker-entrypoint change (don't bake installed servers) is what prevents
172
+ // stale uninstalls from persisting.
173
+ const base = {
174
+ mcpServers: {
175
+ "manually-configured": { type: "http", url: "https://x.test" },
176
+ },
177
+ };
178
+ const installed = {}; // Empty — nothing installed via API
179
+ const merged = mergeMcpConfig(base, installed, TASK_ID);
180
+ expect(merged.mcpServers["manually-configured"]).toBeDefined();
181
+ });
182
+
183
+ test("agent-swarm server gets X-Source-Task-Id injected", () => {
184
+ const base = {
185
+ mcpServers: {
186
+ "agent-swarm": {
187
+ type: "http",
188
+ url: "http://localhost:3013/mcp",
189
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
190
+ },
191
+ },
192
+ };
193
+ const merged = mergeMcpConfig(base, null, TASK_ID);
194
+ const agentSwarm = merged.mcpServers["agent-swarm"] as Record<string, unknown>;
195
+ const headers = agentSwarm.headers as Record<string, string>;
196
+ expect(headers["X-Source-Task-Id"]).toBe(TASK_ID);
197
+ // Existing headers preserved
198
+ expect(headers.Authorization).toBe("Bearer KEY");
199
+ expect(headers["X-Agent-ID"]).toBe("a1");
200
+ });
201
+
202
+ test("X-Source-Task-Id injection works on entry discovered by X-Agent-ID header", () => {
203
+ // Discovery path for non-standard server names.
204
+ const base = {
205
+ mcpServers: {
206
+ "custom-name-swarm": {
207
+ type: "http",
208
+ url: "http://localhost:3013/mcp",
209
+ headers: { Authorization: "Bearer KEY", "X-Agent-ID": "a1" },
210
+ },
211
+ },
212
+ };
213
+ const merged = mergeMcpConfig(base, null, TASK_ID);
214
+ const entry = merged.mcpServers["custom-name-swarm"] as Record<string, unknown>;
215
+ expect((entry.headers as Record<string, string>)["X-Source-Task-Id"]).toBe(TASK_ID);
216
+ });
217
+
218
+ test("does not mutate the input baseConfig", () => {
219
+ const base = {
220
+ mcpServers: {
221
+ stripe: {
222
+ type: "http",
223
+ url: "https://mcp.stripe.com",
224
+ headers: { Authorization: "Bearer STALE" },
225
+ },
226
+ },
227
+ };
228
+ const installed = {
229
+ stripe: {
230
+ type: "http",
231
+ url: "https://mcp.stripe.com",
232
+ headers: { Authorization: "Bearer FRESH" },
233
+ },
234
+ };
235
+ mergeMcpConfig(base, installed, TASK_ID);
236
+ // Original object should be untouched
237
+ expect((base.mcpServers.stripe.headers as Record<string, string>).Authorization).toBe(
238
+ "Bearer STALE",
239
+ );
240
+ });
241
+
242
+ test("empty base + empty installed yields empty mcpServers", () => {
243
+ const merged = mergeMcpConfig({ mcpServers: {} }, {}, TASK_ID);
244
+ expect(Object.keys(merged.mcpServers)).toHaveLength(0);
245
+ });
246
+ });
247
+
106
248
  describe("Stale session retry logic", () => {
107
249
  test("--resume args are stripped correctly", () => {
108
250
  const args = ["--max-turns", "10", "--resume", "session-abc", "--verbose"];
@@ -0,0 +1,87 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import {
4
+ closeDb,
5
+ completeTask,
6
+ createAgent,
7
+ createTaskExtended,
8
+ getInProgressTasksByContextKey,
9
+ initDb,
10
+ } from "../be/db";
11
+ import { slackContextKey } from "../tasks/context-key";
12
+
13
+ const TEST_DB_PATH = "./test-context-key-db.sqlite";
14
+
15
+ beforeAll(() => {
16
+ initDb(TEST_DB_PATH);
17
+ });
18
+
19
+ afterAll(() => {
20
+ closeDb();
21
+ try {
22
+ unlinkSync(TEST_DB_PATH);
23
+ unlinkSync(`${TEST_DB_PATH}-wal`);
24
+ unlinkSync(`${TEST_DB_PATH}-shm`);
25
+ } catch {
26
+ // ignore
27
+ }
28
+ });
29
+
30
+ describe("contextKey persistence + lookup", () => {
31
+ test("createTaskExtended persists contextKey and getInProgressTasksByContextKey returns it", () => {
32
+ const agent = createAgent({
33
+ name: "ctx-key-agent-1",
34
+ isLead: false,
35
+ status: "idle",
36
+ capabilities: [],
37
+ });
38
+
39
+ const key = slackContextKey({ channelId: "C_TEST_1", threadTs: "1700000000.000001" });
40
+ const task = createTaskExtended("Hello", { agentId: agent.id, contextKey: key });
41
+
42
+ expect(task.contextKey).toBe(key);
43
+
44
+ const siblings = getInProgressTasksByContextKey(key);
45
+ expect(siblings.map((t) => t.id)).toContain(task.id);
46
+ });
47
+
48
+ test("getInProgressTasksByContextKey excludes terminal tasks", () => {
49
+ const agent = createAgent({
50
+ name: "ctx-key-agent-2",
51
+ isLead: false,
52
+ status: "idle",
53
+ capabilities: [],
54
+ });
55
+
56
+ const key = slackContextKey({ channelId: "C_TEST_2", threadTs: "1700000000.000002" });
57
+ const done = createTaskExtended("Done task", { agentId: agent.id, contextKey: key });
58
+ const pending = createTaskExtended("Pending task", { agentId: agent.id, contextKey: key });
59
+
60
+ completeTask(done.id, "ok");
61
+
62
+ const siblings = getInProgressTasksByContextKey(key);
63
+ const ids = siblings.map((t) => t.id);
64
+ expect(ids).toContain(pending.id);
65
+ expect(ids).not.toContain(done.id);
66
+ });
67
+
68
+ test("getInProgressTasksByContextKey returns empty for unknown key", () => {
69
+ const results = getInProgressTasksByContextKey("task:slack:C_NONE:0");
70
+ expect(results).toEqual([]);
71
+ });
72
+
73
+ test("child task inherits contextKey from parent", () => {
74
+ const agent = createAgent({
75
+ name: "ctx-key-agent-3",
76
+ isLead: false,
77
+ status: "idle",
78
+ capabilities: [],
79
+ });
80
+
81
+ const key = slackContextKey({ channelId: "C_TEST_3", threadTs: "1700000000.000003" });
82
+ const parent = createTaskExtended("Parent", { agentId: agent.id, contextKey: key });
83
+ const child = createTaskExtended("Child", { agentId: agent.id, parentTaskId: parent.id });
84
+
85
+ expect(child.contextKey).toBe(key);
86
+ });
87
+ });