@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,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
|
+
});
|