@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.
- package/README.md +3 -3
- package/openapi.json +62 -1
- package/package.json +1 -1
- package/src/agentmail/handlers.ts +87 -6
- package/src/be/db.ts +34 -2
- package/src/be/migrations/042_task_context_key.sql +13 -0
- package/src/commands/runner.ts +1 -0
- package/src/github/handlers.ts +42 -10
- package/src/gitlab/handlers.ts +29 -5
- package/src/hooks/hook.ts +4 -2
- package/src/http/core.ts +36 -26
- package/src/http/mcp-oauth.ts +132 -60
- package/src/http/mcp-servers.ts +5 -1
- package/src/http/schedules.ts +4 -2
- package/src/http/tasks.ts +4 -2
- package/src/linear/sync.ts +22 -10
- package/src/providers/claude-adapter.ts +51 -29
- package/src/scheduler/scheduler.ts +9 -10
- package/src/server.ts +2 -0
- package/src/slack/actions.ts +10 -9
- package/src/slack/assistant.ts +8 -4
- package/src/slack/handlers.ts +8 -3
- package/src/slack/thread-buffer.ts +61 -72
- package/src/tasks/additive-buffer.ts +152 -0
- package/src/tasks/additive-ingress.ts +125 -0
- package/src/tasks/context-key.ts +245 -0
- package/src/tasks/sibling-awareness.ts +144 -0
- package/src/tasks/sibling-block.ts +164 -0
- package/src/tests/additive-buffer.test.ts +186 -0
- package/src/tests/additive-ingress.test.ts +111 -0
- package/src/tests/claude-adapter.test.ts +143 -1
- package/src/tests/context-key-db.test.ts +87 -0
- package/src/tests/context-key.test.ts +173 -0
- package/src/tests/core-auth.test.ts +142 -0
- package/src/tests/mcp-oauth-resolve-secrets.test.ts +79 -0
- package/src/tests/sibling-awareness-db.test.ts +172 -0
- package/src/tests/sibling-block.test.ts +232 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tools/slack-post.ts +10 -3
- package/src/tools/slack-start-thread.ts +123 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +5 -2
- package/src/types.ts +5 -0
- 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
|
+
});
|