@desplega.ai/agent-swarm 1.83.1 → 1.84.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/openapi.json +158 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/http/webhooks.ts +101 -0
- package/src/integrations/kapso/client.ts +198 -0
- package/src/integrations/kapso/config.ts +104 -0
- package/src/integrations/kapso/inbound.ts +111 -0
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/server.ts +14 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +198 -0
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/register-kapso-number.ts +210 -0
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/tools/templates.ts +35 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/tools/whatsapp-message.ts +135 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/templates/skills/agentmail-sending/SKILL.md +49 -0
- package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
|
|
4
|
+
import { createPiRuntimeAuth, PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
|
|
5
5
|
|
|
6
6
|
describe("PiMonoAdapter", () => {
|
|
7
7
|
test("name is 'pi'", () => {
|
|
@@ -177,6 +177,26 @@ describe("resolveModel — OpenRouter reroute for anthropic shortnames", () => {
|
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
describe("createPiRuntimeAuth", () => {
|
|
181
|
+
test("threads resolved OpenRouter key into pi runtime auth without process.env", async () => {
|
|
182
|
+
const { modelRegistry } = createPiRuntimeAuth({ OPENROUTER_API_KEY: "sk-or-runtime" });
|
|
183
|
+
|
|
184
|
+
await expect(modelRegistry.getApiKeyForProvider("openrouter")).resolves.toBe("sk-or-runtime");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("supports all pi env-backed providers", async () => {
|
|
188
|
+
const { modelRegistry } = createPiRuntimeAuth({
|
|
189
|
+
ANTHROPIC_API_KEY: "sk-ant-runtime",
|
|
190
|
+
OPENAI_API_KEY: "sk-openai-runtime",
|
|
191
|
+
GOOGLE_API_KEY: "sk-google-runtime",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await expect(modelRegistry.getApiKeyForProvider("anthropic")).resolves.toBe("sk-ant-runtime");
|
|
195
|
+
await expect(modelRegistry.getApiKeyForProvider("openai")).resolves.toBe("sk-openai-runtime");
|
|
196
|
+
await expect(modelRegistry.getApiKeyForProvider("google")).resolves.toBe("sk-google-runtime");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
180
200
|
describe("Pi-mono event normalization", () => {
|
|
181
201
|
test("message_update with text content produces raw_log-style data", () => {
|
|
182
202
|
// Simulates what PiMonoSession.handleAgentEvent does
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isRateLimitMessage,
|
|
4
|
+
MAX_RATE_LIMIT_RESET_MS,
|
|
5
|
+
parseStderrForErrors,
|
|
6
|
+
SessionErrorTracker,
|
|
7
|
+
trackErrorFromJson,
|
|
8
|
+
} from "../utils/error-tracker";
|
|
3
9
|
|
|
4
10
|
// Verbatim fixture from Linear CAI-1279 (session logs for task b7fbbdb9-4922-41d9-88ec-21febd6c4fec)
|
|
5
11
|
const FIXTURE_REJECTED = {
|
|
@@ -25,7 +31,7 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
|
|
|
25
31
|
expect(result).toBeDefined();
|
|
26
32
|
|
|
27
33
|
// resetsAt: 1779202200 sec → 2026-05-19T14:50:00.000Z
|
|
28
|
-
// But since we clamp to [now+60s, now+
|
|
34
|
+
// But since we clamp to [now+60s, now+7d] and this is a past timestamp,
|
|
29
35
|
// the value will be clamped to now+60s. What matters is the sec→ms conversion works.
|
|
30
36
|
// We verify the unit is correct by checking that 1779202200 * 1000 = ms,
|
|
31
37
|
// which is NOT the same as treating it as ms (would be 1970-01-21).
|
|
@@ -125,7 +131,7 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
|
|
|
125
131
|
expect(parsedMs).toBeLessThanOrEqual(nowMs + 65_000);
|
|
126
132
|
});
|
|
127
133
|
|
|
128
|
-
test("resetsAt absurdly far in future → clamped to now+
|
|
134
|
+
test("resetsAt absurdly far in future → clamped to now+7d (malformed defense)", () => {
|
|
129
135
|
const tracker = new SessionErrorTracker();
|
|
130
136
|
// Year 2099 in seconds
|
|
131
137
|
tracker.processRateLimitEvent({
|
|
@@ -137,8 +143,25 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
|
|
|
137
143
|
expect(result).toBeDefined();
|
|
138
144
|
const parsedMs = new Date(result!).getTime();
|
|
139
145
|
const nowMs = Date.now();
|
|
140
|
-
const
|
|
141
|
-
expect(parsedMs).toBeLessThanOrEqual(nowMs +
|
|
146
|
+
const sevenDaysMs = MAX_RATE_LIMIT_RESET_MS;
|
|
147
|
+
expect(parsedMs).toBeLessThanOrEqual(nowMs + sevenDaysMs + 1000); // within 7d (+1s tolerance)
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("legitimate weekly reset (~2 days out) is honored, not clamped to 6h", () => {
|
|
151
|
+
const tracker = new SessionErrorTracker();
|
|
152
|
+
const twoDaysFromNowSec = Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60;
|
|
153
|
+
tracker.processRateLimitEvent({
|
|
154
|
+
type: "rate_limit_event",
|
|
155
|
+
rate_limit_info: { status: "rejected", resetsAt: twoDaysFromNowSec, rateLimitType: "weekly" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = tracker.getRateLimitResetAt();
|
|
159
|
+
expect(result).toBeDefined();
|
|
160
|
+
const parsedMs = new Date(result!).getTime();
|
|
161
|
+
const nowMs = Date.now();
|
|
162
|
+
// The real reset is ~2 days out — must be honored, not capped at 6h.
|
|
163
|
+
expect(parsedMs).toBeGreaterThan(nowMs + 6 * 60 * 60 * 1000);
|
|
164
|
+
expect(parsedMs).toBeLessThanOrEqual(nowMs + 2 * 24 * 60 * 60 * 1000 + 1000);
|
|
142
165
|
});
|
|
143
166
|
|
|
144
167
|
test("multiple rate_limit_event lines → last rejected one wins", () => {
|
|
@@ -255,7 +278,7 @@ describe("three-tier resolver logic (unit test via clamp helper)", () => {
|
|
|
255
278
|
function clampResetTime(isoString: string): string {
|
|
256
279
|
const nowMs = Date.now();
|
|
257
280
|
const minMs = nowMs + 60_000;
|
|
258
|
-
const maxMs = nowMs +
|
|
281
|
+
const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
|
|
259
282
|
const candidateMs = new Date(isoString).getTime();
|
|
260
283
|
return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
|
|
261
284
|
}
|
|
@@ -290,3 +313,43 @@ describe("three-tier resolver logic (unit test via clamp helper)", () => {
|
|
|
290
313
|
expect(resolvedMs).toBeLessThanOrEqual(nowMs + 6 * 60_000);
|
|
291
314
|
});
|
|
292
315
|
});
|
|
316
|
+
|
|
317
|
+
describe("isRateLimitMessage — shared matcher (runner gate + stderr parser)", () => {
|
|
318
|
+
test.each([
|
|
319
|
+
"You've hit your weekly limit · resets May 28, 5pm (UTC)",
|
|
320
|
+
"hit your 5-hour limit",
|
|
321
|
+
"hit your limit",
|
|
322
|
+
"Claude usage limit reached",
|
|
323
|
+
"hit your daily limit",
|
|
324
|
+
"rate limit exceeded",
|
|
325
|
+
"rate_limit error",
|
|
326
|
+
"429 Too Many Requests",
|
|
327
|
+
"Error: too many requests, slow down",
|
|
328
|
+
"[rate-limit] codex prefix",
|
|
329
|
+
"[usage-limit] codex prefix",
|
|
330
|
+
])("matches rate-limit signal: %s", (msg) => {
|
|
331
|
+
expect(isRateLimitMessage(msg)).toBe(true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test.each([
|
|
335
|
+
"No conversation found with session ID abc",
|
|
336
|
+
"Max turns exceeded",
|
|
337
|
+
"Authentication failed: invalid token",
|
|
338
|
+
"Error during execution: file not found",
|
|
339
|
+
"Read 4290 bytes from stream",
|
|
340
|
+
])("does NOT match non-rate-limit text: %s", (msg) => {
|
|
341
|
+
expect(isRateLimitMessage(msg)).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("parseStderrForErrors flags a weekly-limit stderr line as an error", () => {
|
|
345
|
+
const tracker = new SessionErrorTracker();
|
|
346
|
+
parseStderrForErrors("You've hit your weekly limit · resets May 28, 5pm (UTC)", tracker);
|
|
347
|
+
expect(tracker.hasErrors()).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("parseStderrForErrors still flags a bare 429 stderr line", () => {
|
|
351
|
+
const tracker = new SessionErrorTracker();
|
|
352
|
+
parseStderrForErrors("HTTP 429 returned by upstream", tracker);
|
|
353
|
+
expect(tracker.hasErrors()).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveResumeSession } from "../commands/resume-session";
|
|
3
|
+
|
|
4
|
+
describe("resolveResumeSession", () => {
|
|
5
|
+
test("allows local Claude resume for UUID session ids", () => {
|
|
6
|
+
const resolution = resolveResumeSession("claude", [
|
|
7
|
+
{
|
|
8
|
+
source: "task",
|
|
9
|
+
sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
|
|
10
|
+
provider: "claude",
|
|
11
|
+
},
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
|
|
15
|
+
expect(resolution.source).toBe("task");
|
|
16
|
+
expect(resolution.provider).toBe("claude");
|
|
17
|
+
expect(resolution.skipped).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects non-UUID ids for local Claude resume", () => {
|
|
21
|
+
const resolution = resolveResumeSession("claude", [
|
|
22
|
+
{
|
|
23
|
+
source: "task",
|
|
24
|
+
sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
|
|
25
|
+
provider: "claude",
|
|
26
|
+
},
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
30
|
+
expect(resolution.skipped).toHaveLength(1);
|
|
31
|
+
expect(resolution.skipped[0]?.reason).toBe("Claude CLI --resume requires a UUID session id");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("normalizes legacy managed Claude rows to claude-managed", () => {
|
|
35
|
+
const resolution = resolveResumeSession("claude-managed", [
|
|
36
|
+
{
|
|
37
|
+
source: "parent",
|
|
38
|
+
sessionId: "sesn_resume_xyz",
|
|
39
|
+
provider: "claude",
|
|
40
|
+
providerMeta: { managed: true },
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
expect(resolution.resumeSessionId).toBe("sesn_resume_xyz");
|
|
45
|
+
expect(resolution.source).toBe("parent");
|
|
46
|
+
expect(resolution.provider).toBe("claude-managed");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("skips mismatched provider sessions and falls back to parent", () => {
|
|
50
|
+
const resolution = resolveResumeSession("claude", [
|
|
51
|
+
{
|
|
52
|
+
source: "task",
|
|
53
|
+
sessionId: "thread-codex",
|
|
54
|
+
provider: "codex",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
source: "parent",
|
|
58
|
+
sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
|
|
59
|
+
provider: "claude",
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
|
|
64
|
+
expect(resolution.source).toBe("parent");
|
|
65
|
+
expect(resolution.skipped).toHaveLength(1);
|
|
66
|
+
expect(resolution.skipped[0]?.reason).toContain("does not match current provider");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects legacy unknown non-UUID Claude session ids", () => {
|
|
70
|
+
const resolution = resolveResumeSession("claude", [
|
|
71
|
+
{
|
|
72
|
+
source: "task",
|
|
73
|
+
sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
78
|
+
expect(resolution.skipped[0]?.reason).toBe("legacy Claude resume requires a UUID session id");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("does not resume providers without runner resume support", () => {
|
|
82
|
+
const resolution = resolveResumeSession("pi", [
|
|
83
|
+
{
|
|
84
|
+
source: "task",
|
|
85
|
+
sessionId: "pi-session",
|
|
86
|
+
provider: "pi",
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
expect(resolution.resumeSessionId).toBeUndefined();
|
|
91
|
+
expect(resolution.skipped[0]?.reason).toBe("provider pi does not support runner resume");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createTaskExtended, createUser, getTaskById, initDb } from "../be/db";
|
|
4
|
+
import { getTasksHandler } from "../tools/get-tasks";
|
|
5
|
+
import { sendTaskHandler } from "../tools/send-task";
|
|
6
|
+
import { assertOwnsTask, ownerCtx, userCtx } from "../tools/task-tool-ctx";
|
|
7
|
+
|
|
8
|
+
const TEST_DB_PATH = "./test-task-tools-ctx.sqlite";
|
|
9
|
+
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
12
|
+
try {
|
|
13
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
initDb(TEST_DB_PATH);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
closeDb();
|
|
21
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
22
|
+
try {
|
|
23
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("task tool ctx", () => {
|
|
29
|
+
test("sendTaskHandler with user ctx writes requestedByUserId", async () => {
|
|
30
|
+
const user = createUser({ name: "MCP User" });
|
|
31
|
+
|
|
32
|
+
const result = await sendTaskHandler(userCtx(user), {
|
|
33
|
+
task: "user requested task",
|
|
34
|
+
offerMode: false,
|
|
35
|
+
allowDuplicate: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const structured = result.structuredContent as {
|
|
39
|
+
success: boolean;
|
|
40
|
+
task: { id: string; requestedByUserId?: string };
|
|
41
|
+
};
|
|
42
|
+
expect(structured.success).toBe(true);
|
|
43
|
+
expect(structured.task.requestedByUserId).toBe(user.id);
|
|
44
|
+
|
|
45
|
+
const stored = getTaskById(structured.task.id);
|
|
46
|
+
expect(stored?.creatorAgentId).toBeUndefined();
|
|
47
|
+
expect(stored?.requestedByUserId).toBe(user.id);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("getTasksHandler with user ctx only returns that user's tasks", async () => {
|
|
51
|
+
const userA = createUser({ name: "List User A" });
|
|
52
|
+
const userB = createUser({ name: "List User B" });
|
|
53
|
+
|
|
54
|
+
const a1 = createTaskExtended("owned task one", { requestedByUserId: userA.id });
|
|
55
|
+
const a2 = createTaskExtended("owned task two", { requestedByUserId: userA.id });
|
|
56
|
+
const b1 = createTaskExtended("foreign task", { requestedByUserId: userB.id });
|
|
57
|
+
createTaskExtended("owner-only task");
|
|
58
|
+
|
|
59
|
+
const result = await getTasksHandler(userCtx(userA), {
|
|
60
|
+
includeFull: true,
|
|
61
|
+
includeHeartbeat: true,
|
|
62
|
+
limit: 50,
|
|
63
|
+
mineOnly: true,
|
|
64
|
+
offeredToMe: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const structured = result.structuredContent as {
|
|
68
|
+
tasks: Array<{ id: string; task?: string }>;
|
|
69
|
+
};
|
|
70
|
+
const ids = structured.tasks.map((task) => task.id);
|
|
71
|
+
expect(ids).toContain(a1.id);
|
|
72
|
+
expect(ids).toContain(a2.id);
|
|
73
|
+
expect(ids).not.toContain(b1.id);
|
|
74
|
+
expect(structured.tasks.every((task) => task.task?.startsWith("owned task"))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("assertOwnsTask gates user tasks and allows owned or owner ctx", () => {
|
|
78
|
+
const owner = createUser({ name: "Task Owner" });
|
|
79
|
+
const foreignUser = createUser({ name: "Foreign User" });
|
|
80
|
+
const ownedTask = createTaskExtended("owned", { requestedByUserId: owner.id });
|
|
81
|
+
|
|
82
|
+
expect(assertOwnsTask(userCtx(owner), ownedTask)).toBeNull();
|
|
83
|
+
expect(
|
|
84
|
+
assertOwnsTask(
|
|
85
|
+
ownerCtx({
|
|
86
|
+
agentId: "00000000-0000-4000-8000-000000000001",
|
|
87
|
+
sourceTaskId: undefined,
|
|
88
|
+
sessionId: "session-1",
|
|
89
|
+
}),
|
|
90
|
+
ownedTask,
|
|
91
|
+
),
|
|
92
|
+
).toBeNull();
|
|
93
|
+
|
|
94
|
+
const forbidden = assertOwnsTask(userCtx(foreignUser), ownedTask);
|
|
95
|
+
expect(forbidden?.isError).toBe(true);
|
|
96
|
+
expect(forbidden?.content[0]?.type).toBe("text");
|
|
97
|
+
expect(forbidden?.content[0]?.text).toContain("this task is not yours");
|
|
98
|
+
expect((forbidden?.structuredContent as { code?: string })?.code).toBe("forbidden");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createTaskExtended,
|
|
7
|
+
createUser,
|
|
8
|
+
getDb,
|
|
9
|
+
getTaskById,
|
|
10
|
+
initDb,
|
|
11
|
+
} from "../be/db";
|
|
12
|
+
import { cancelTaskHandler } from "../tools/cancel-task";
|
|
13
|
+
import { getTaskDetailsHandler } from "../tools/get-task-details";
|
|
14
|
+
import { taskActionHandler } from "../tools/task-action";
|
|
15
|
+
import { ownerCtx, userCtx } from "../tools/task-tool-ctx";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = "./test-task-tools-ownership.sqlite";
|
|
18
|
+
|
|
19
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
20
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
21
|
+
try {
|
|
22
|
+
await unlink(path + suffix);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
29
|
+
initDb(TEST_DB_PATH);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(async () => {
|
|
33
|
+
closeDb();
|
|
34
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
db.prepare("DELETE FROM agent_tasks").run();
|
|
40
|
+
db.prepare("DELETE FROM agents").run();
|
|
41
|
+
db.prepare("DELETE FROM users").run();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function expectForbidden(result: Awaited<ReturnType<typeof getTaskDetailsHandler>>): void {
|
|
45
|
+
expect(result.isError).toBe(true);
|
|
46
|
+
expect(result.content[0]?.type).toBe("text");
|
|
47
|
+
expect(result.content[0]?.text).toContain("this task is not yours");
|
|
48
|
+
expect((result.structuredContent as { code?: string })?.code).toBe("forbidden");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("ownership-gated task tools", () => {
|
|
52
|
+
test("getTaskDetailsHandler gates user ctx and leaves owner ctx visible", async () => {
|
|
53
|
+
const owner = createUser({ name: "Task Owner" });
|
|
54
|
+
const foreignUser = createUser({ name: "Foreign User" });
|
|
55
|
+
const task = createTaskExtended("owned details", { requestedByUserId: owner.id });
|
|
56
|
+
|
|
57
|
+
expectForbidden(await getTaskDetailsHandler(userCtx(foreignUser), { taskId: task.id }));
|
|
58
|
+
|
|
59
|
+
const userResult = await getTaskDetailsHandler(userCtx(owner), { taskId: task.id });
|
|
60
|
+
expect(
|
|
61
|
+
(userResult.structuredContent as { success: boolean; task?: { id: string } }).success,
|
|
62
|
+
).toBe(true);
|
|
63
|
+
expect((userResult.structuredContent as { task?: { id: string } }).task?.id).toBe(task.id);
|
|
64
|
+
|
|
65
|
+
const ownerResult = await getTaskDetailsHandler(
|
|
66
|
+
ownerCtx({
|
|
67
|
+
agentId: "00000000-0000-4000-8000-000000000001",
|
|
68
|
+
}),
|
|
69
|
+
{ taskId: task.id },
|
|
70
|
+
);
|
|
71
|
+
expect((ownerResult.structuredContent as { success: boolean }).success).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("cancelTaskHandler gates user ctx and preserves owner lead permission", async () => {
|
|
75
|
+
const owner = createUser({ name: "Cancel Owner" });
|
|
76
|
+
const foreignUser = createUser({ name: "Cancel Foreign" });
|
|
77
|
+
const task = createTaskExtended("owned cancellation", { requestedByUserId: owner.id });
|
|
78
|
+
|
|
79
|
+
expectForbidden(
|
|
80
|
+
await cancelTaskHandler(userCtx(foreignUser), {
|
|
81
|
+
taskId: task.id,
|
|
82
|
+
reason: "foreign attempt",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
expect(getTaskById(task.id)?.status).toBe("unassigned");
|
|
86
|
+
|
|
87
|
+
const userResult = await cancelTaskHandler(userCtx(owner), {
|
|
88
|
+
taskId: task.id,
|
|
89
|
+
reason: "owned cancel",
|
|
90
|
+
});
|
|
91
|
+
expect(
|
|
92
|
+
(userResult.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect((userResult.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
95
|
+
"cancelled",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const lead = createAgent({ name: "lead", isLead: true, status: "idle", maxTasks: 1 });
|
|
99
|
+
const leadTask = createTaskExtended("lead cancellation");
|
|
100
|
+
const ownerResult = await cancelTaskHandler(ownerCtx({ agentId: lead.id }), {
|
|
101
|
+
taskId: leadTask.id,
|
|
102
|
+
reason: "lead cancel",
|
|
103
|
+
});
|
|
104
|
+
expect((ownerResult.structuredContent as { success: boolean }).success).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("taskActionHandler gates user backlog moves and rejects agent-only actions", async () => {
|
|
108
|
+
const owner = createUser({ name: "Backlog Owner" });
|
|
109
|
+
const foreignUser = createUser({ name: "Backlog Foreign" });
|
|
110
|
+
const task = createTaskExtended("owned backlog move", { requestedByUserId: owner.id });
|
|
111
|
+
|
|
112
|
+
expectForbidden(
|
|
113
|
+
await taskActionHandler(userCtx(foreignUser), {
|
|
114
|
+
action: "to_backlog",
|
|
115
|
+
taskId: task.id,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(getTaskById(task.id)?.status).toBe("unassigned");
|
|
119
|
+
|
|
120
|
+
const toBacklog = await taskActionHandler(userCtx(owner), {
|
|
121
|
+
action: "to_backlog",
|
|
122
|
+
taskId: task.id,
|
|
123
|
+
});
|
|
124
|
+
expect(
|
|
125
|
+
(toBacklog.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
126
|
+
).toBe(true);
|
|
127
|
+
expect((toBacklog.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
128
|
+
"backlog",
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const fromBacklog = await taskActionHandler(userCtx(owner), {
|
|
132
|
+
action: "from_backlog",
|
|
133
|
+
taskId: task.id,
|
|
134
|
+
});
|
|
135
|
+
expect(
|
|
136
|
+
(fromBacklog.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
137
|
+
).toBe(true);
|
|
138
|
+
expect((fromBacklog.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
139
|
+
"unassigned",
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const rejected = await taskActionHandler(userCtx(owner), {
|
|
143
|
+
action: "create",
|
|
144
|
+
task: "duplicate create path",
|
|
145
|
+
});
|
|
146
|
+
expect(rejected.isError).toBe(true);
|
|
147
|
+
expect(rejected.content[0]?.type).toBe("text");
|
|
148
|
+
expect(rejected.content[0]?.text).toContain("only available to worker agents");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("taskActionHandler owner ctx preserves worker release behavior", async () => {
|
|
152
|
+
const worker = createAgent({ name: "worker", isLead: false, status: "idle", maxTasks: 1 });
|
|
153
|
+
const task = createTaskExtended("assigned task", { agentId: worker.id });
|
|
154
|
+
|
|
155
|
+
const result = await taskActionHandler(ownerCtx({ agentId: worker.id }), {
|
|
156
|
+
action: "release",
|
|
157
|
+
taskId: task.id,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(
|
|
161
|
+
(result.structuredContent as { success: boolean; task?: { status: string } }).success,
|
|
162
|
+
).toBe(true);
|
|
163
|
+
expect((result.structuredContent as { task?: { status: string } }).task?.status).toBe(
|
|
164
|
+
"unassigned",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -323,9 +323,10 @@ describe("Tool Annotations & Classification", () => {
|
|
|
323
323
|
test("registered tool count matches expected total", () => {
|
|
324
324
|
const count = Object.keys(tools).length;
|
|
325
325
|
// We expect all tools to be registered when all capabilities are enabled (default)
|
|
326
|
-
// Includes 11 skill tools, 7 MCP server tools,
|
|
326
|
+
// Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
|
|
327
|
+
// native Kapso/WhatsApp tools (register/unregister number + send/reply message).
|
|
327
328
|
expect(count).toBeGreaterThanOrEqual(45);
|
|
328
|
-
expect(count).toBeLessThanOrEqual(
|
|
329
|
+
expect(count).toBeLessThanOrEqual(120);
|
|
329
330
|
});
|
|
330
331
|
|
|
331
332
|
test("core tools are fewer than deferred tools", () => {
|