@desplega.ai/agent-swarm 1.87.0 → 1.89.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 +5 -1
- package/openapi.json +53 -1
- package/package.json +6 -5
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +374 -9
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3825 -2417
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +179 -0
- package/src/cli.tsx +51 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +154 -22
- package/src/commands/x.ts +118 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +13 -1
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/skills.ts +53 -0
- package/src/http/tasks.ts +4 -1
- package/src/http/webhooks.ts +75 -0
- package/src/http/workflows.ts +5 -1
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +24 -1
- package/src/prompts/session-templates.ts +74 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +72 -7
- package/src/server.ts +10 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +76 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +116 -1
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +185 -30
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +122 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +121 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the runtime-config flags that control whether GitHub unassign and
|
|
3
|
+
* review-request-removed events terminate the linked swarm task.
|
|
4
|
+
*
|
|
5
|
+
* Flags (scope "global"):
|
|
6
|
+
* github.cancelOnUnassign — PR unassigned + issue unassigned
|
|
7
|
+
* github.cancelOnReviewRequestRemoved — PR review_request_removed
|
|
8
|
+
*
|
|
9
|
+
* Absent / any value ≠ "false" → cancel (current behavior, default).
|
|
10
|
+
* Value "false" → leave task untouched, return { created: false }.
|
|
11
|
+
*/
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { unlink } from "node:fs/promises";
|
|
14
|
+
import {
|
|
15
|
+
closeDb,
|
|
16
|
+
createAgent,
|
|
17
|
+
createTaskExtended,
|
|
18
|
+
deleteSwarmConfig,
|
|
19
|
+
getDb,
|
|
20
|
+
getSwarmConfigs,
|
|
21
|
+
getTaskById,
|
|
22
|
+
initDb,
|
|
23
|
+
upsertSwarmConfig,
|
|
24
|
+
} from "../be/db";
|
|
25
|
+
import { handleIssue, handlePullRequest } from "../github/handlers";
|
|
26
|
+
import { GITHUB_BOT_NAME } from "../github/mentions";
|
|
27
|
+
import type { IssueEvent, PullRequestEvent } from "../github/types";
|
|
28
|
+
|
|
29
|
+
const TEST_DB_PATH = "./test-github-handlers-cancel-config.sqlite";
|
|
30
|
+
|
|
31
|
+
const BASE_REPO = { full_name: "test/repo", html_url: "https://github.com/test/repo" };
|
|
32
|
+
const BASE_PR = {
|
|
33
|
+
number: 1,
|
|
34
|
+
title: "Test PR",
|
|
35
|
+
body: null as string | null,
|
|
36
|
+
html_url: "https://github.com/test/repo/pull/1",
|
|
37
|
+
user: { login: "sender" },
|
|
38
|
+
head: { ref: "feature", sha: "abc1234567890" },
|
|
39
|
+
base: { ref: "main" },
|
|
40
|
+
merged: false,
|
|
41
|
+
merged_by: undefined,
|
|
42
|
+
};
|
|
43
|
+
const BASE_ISSUE = {
|
|
44
|
+
number: 10,
|
|
45
|
+
title: "Test Issue",
|
|
46
|
+
body: null as string | null,
|
|
47
|
+
html_url: "https://github.com/test/repo/issues/10",
|
|
48
|
+
user: { login: "sender" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Setup ──
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
55
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
56
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
57
|
+
initDb(TEST_DB_PATH);
|
|
58
|
+
createAgent({
|
|
59
|
+
id: "lead-cancel-config-test",
|
|
60
|
+
name: "CancelConfigTestLead",
|
|
61
|
+
status: "idle",
|
|
62
|
+
isLead: true,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
closeDb();
|
|
68
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
69
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
70
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Clear tasks and config rows between tests.
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
const db = getDb();
|
|
76
|
+
db.prepare("DELETE FROM agent_tasks").run();
|
|
77
|
+
// Remove both flag rows so each test starts from a clean slate.
|
|
78
|
+
for (const key of ["github.cancelOnUnassign", "github.cancelOnReviewRequestRemoved"]) {
|
|
79
|
+
const rows = getSwarmConfigs({ scope: "global", key });
|
|
80
|
+
for (const row of rows) deleteSwarmConfig(row.id);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Helpers ──
|
|
85
|
+
|
|
86
|
+
function seedTask(vcsNumber: number, kind: "pr" | "issue"): string {
|
|
87
|
+
const task = createTaskExtended("test task", {
|
|
88
|
+
agentId: "lead-cancel-config-test",
|
|
89
|
+
source: "github",
|
|
90
|
+
vcsProvider: "github",
|
|
91
|
+
vcsRepo: BASE_REPO.full_name,
|
|
92
|
+
vcsNumber,
|
|
93
|
+
vcsEventType: kind === "pr" ? "pull_request" : "issues",
|
|
94
|
+
});
|
|
95
|
+
return task.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setConfigFlag(key: string, value: string) {
|
|
99
|
+
upsertSwarmConfig({ scope: "global", key, value });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getTaskStatus(taskId: string): string | undefined {
|
|
103
|
+
return getTaskById(taskId)?.status;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── github.cancelOnUnassign — PR unassigned ──
|
|
107
|
+
|
|
108
|
+
describe("PR unassigned — github.cancelOnUnassign", () => {
|
|
109
|
+
test("default (no config row): unassign cancels the task", async () => {
|
|
110
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
111
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
112
|
+
|
|
113
|
+
const event: PullRequestEvent = {
|
|
114
|
+
action: "unassigned",
|
|
115
|
+
pull_request: { ...BASE_PR },
|
|
116
|
+
repository: BASE_REPO,
|
|
117
|
+
sender: { login: "someone" },
|
|
118
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
119
|
+
};
|
|
120
|
+
const result = await handlePullRequest(event);
|
|
121
|
+
|
|
122
|
+
expect(result.created).toBe(false);
|
|
123
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("config = 'false': unassign leaves task untouched", async () => {
|
|
127
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
128
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
129
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
130
|
+
|
|
131
|
+
const event: PullRequestEvent = {
|
|
132
|
+
action: "unassigned",
|
|
133
|
+
pull_request: { ...BASE_PR },
|
|
134
|
+
repository: BASE_REPO,
|
|
135
|
+
sender: { login: "someone" },
|
|
136
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
137
|
+
};
|
|
138
|
+
const result = await handlePullRequest(event);
|
|
139
|
+
|
|
140
|
+
expect(result.created).toBe(false);
|
|
141
|
+
// Task must NOT have been failed — still pending.
|
|
142
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── github.cancelOnUnassign — issue unassigned ──
|
|
147
|
+
|
|
148
|
+
describe("issue unassigned — github.cancelOnUnassign", () => {
|
|
149
|
+
test("default (no config row): unassign cancels the task", async () => {
|
|
150
|
+
const taskId = seedTask(BASE_ISSUE.number, "issue");
|
|
151
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
152
|
+
|
|
153
|
+
const event: IssueEvent = {
|
|
154
|
+
action: "unassigned",
|
|
155
|
+
issue: { ...BASE_ISSUE },
|
|
156
|
+
repository: BASE_REPO,
|
|
157
|
+
sender: { login: "someone" },
|
|
158
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
159
|
+
};
|
|
160
|
+
const result = await handleIssue(event);
|
|
161
|
+
|
|
162
|
+
expect(result.created).toBe(false);
|
|
163
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("config = 'false': unassign leaves task untouched", async () => {
|
|
167
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
168
|
+
const taskId = seedTask(BASE_ISSUE.number, "issue");
|
|
169
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
170
|
+
|
|
171
|
+
const event: IssueEvent = {
|
|
172
|
+
action: "unassigned",
|
|
173
|
+
issue: { ...BASE_ISSUE },
|
|
174
|
+
repository: BASE_REPO,
|
|
175
|
+
sender: { login: "someone" },
|
|
176
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
177
|
+
};
|
|
178
|
+
const result = await handleIssue(event);
|
|
179
|
+
|
|
180
|
+
expect(result.created).toBe(false);
|
|
181
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ── github.cancelOnReviewRequestRemoved ──
|
|
186
|
+
|
|
187
|
+
describe("PR review_request_removed — github.cancelOnReviewRequestRemoved", () => {
|
|
188
|
+
test("default (no config row): review removal cancels the task", async () => {
|
|
189
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
190
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
191
|
+
|
|
192
|
+
const event: PullRequestEvent = {
|
|
193
|
+
action: "review_request_removed",
|
|
194
|
+
pull_request: { ...BASE_PR },
|
|
195
|
+
repository: BASE_REPO,
|
|
196
|
+
sender: { login: "someone" },
|
|
197
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
198
|
+
};
|
|
199
|
+
const result = await handlePullRequest(event);
|
|
200
|
+
|
|
201
|
+
expect(result.created).toBe(false);
|
|
202
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("config = 'false': review removal leaves task untouched", async () => {
|
|
206
|
+
setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
|
|
207
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
208
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
209
|
+
|
|
210
|
+
const event: PullRequestEvent = {
|
|
211
|
+
action: "review_request_removed",
|
|
212
|
+
pull_request: { ...BASE_PR },
|
|
213
|
+
repository: BASE_REPO,
|
|
214
|
+
sender: { login: "someone" },
|
|
215
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
216
|
+
};
|
|
217
|
+
const result = await handlePullRequest(event);
|
|
218
|
+
|
|
219
|
+
expect(result.created).toBe(false);
|
|
220
|
+
expect(getTaskStatus(taskId)).toBe("pending");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ── Independence: flags do not bleed into each other ──
|
|
225
|
+
|
|
226
|
+
describe("flag independence", () => {
|
|
227
|
+
test("cancelOnUnassign=false does NOT affect review_request_removed (still cancels)", async () => {
|
|
228
|
+
// Only disable the unassign flag; leave review-request flag absent (default = cancel).
|
|
229
|
+
setConfigFlag("github.cancelOnUnassign", "false");
|
|
230
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
231
|
+
|
|
232
|
+
const event: PullRequestEvent = {
|
|
233
|
+
action: "review_request_removed",
|
|
234
|
+
pull_request: { ...BASE_PR },
|
|
235
|
+
repository: BASE_REPO,
|
|
236
|
+
sender: { login: "someone" },
|
|
237
|
+
requested_reviewer: { login: GITHUB_BOT_NAME, id: 1 },
|
|
238
|
+
};
|
|
239
|
+
await handlePullRequest(event);
|
|
240
|
+
|
|
241
|
+
// review_request_removed STILL cancels because its own flag is absent (default true).
|
|
242
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("cancelOnReviewRequestRemoved=false does NOT affect unassign (still cancels)", async () => {
|
|
246
|
+
// Only disable the review-request flag; leave unassign flag absent (default = cancel).
|
|
247
|
+
setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
|
|
248
|
+
const taskId = seedTask(BASE_PR.number, "pr");
|
|
249
|
+
|
|
250
|
+
const event: PullRequestEvent = {
|
|
251
|
+
action: "unassigned",
|
|
252
|
+
pull_request: { ...BASE_PR },
|
|
253
|
+
repository: BASE_REPO,
|
|
254
|
+
sender: { login: "someone" },
|
|
255
|
+
assignee: { login: GITHUB_BOT_NAME, id: 1 },
|
|
256
|
+
};
|
|
257
|
+
await handlePullRequest(event);
|
|
258
|
+
|
|
259
|
+
// unassigned STILL cancels because its own flag is absent (default true).
|
|
260
|
+
expect(getTaskStatus(taskId)).toBe("failed");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -7,13 +7,17 @@ import {
|
|
|
7
7
|
getActiveSessionForTask,
|
|
8
8
|
getDb,
|
|
9
9
|
getIdleWorkersWithCapacity,
|
|
10
|
+
getOrphanedInProgressTasksForAgent,
|
|
11
|
+
getPendingTaskForAgent,
|
|
10
12
|
getStalledInProgressTasks,
|
|
11
13
|
getTaskById,
|
|
12
14
|
getUnassignedPoolTasks,
|
|
13
15
|
initDb,
|
|
14
16
|
insertActiveSession,
|
|
17
|
+
resetOrphanedInProgressTasksForAgent,
|
|
15
18
|
startTask,
|
|
16
19
|
updateAgentStatus,
|
|
20
|
+
updateTaskClaudeSessionId,
|
|
17
21
|
} from "../be/db";
|
|
18
22
|
import {
|
|
19
23
|
codeLevelTriage,
|
|
@@ -157,6 +161,60 @@ describe("Heartbeat Triage", () => {
|
|
|
157
161
|
});
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
describe("orphaned in_progress recovery", () => {
|
|
165
|
+
test("resets stale in_progress task with no session and no claudeSessionId to pending", () => {
|
|
166
|
+
const agent = createAgent({ name: "orphan-worker", isLead: false, status: "idle" });
|
|
167
|
+
const task = createTaskExtended("Orphaned task", { agentId: agent.id });
|
|
168
|
+
startTask(task.id);
|
|
169
|
+
|
|
170
|
+
const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
|
171
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
|
|
172
|
+
|
|
173
|
+
const orphaned = getOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
174
|
+
expect(orphaned.map((t) => t.id)).toContain(task.id);
|
|
175
|
+
|
|
176
|
+
const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
177
|
+
expect(reset.map((t) => t.id)).toContain(task.id);
|
|
178
|
+
|
|
179
|
+
const updated = getTaskById(task.id);
|
|
180
|
+
expect(updated?.status).toBe("pending");
|
|
181
|
+
expect(getPendingTaskForAgent(agent.id)?.id).toBe(task.id);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("does not reset tasks with active session, provider session, or fresh update", () => {
|
|
185
|
+
const agent = createAgent({ name: "live-worker", isLead: false, status: "idle" });
|
|
186
|
+
const withActiveSession = createTaskExtended("Live session task", { agentId: agent.id });
|
|
187
|
+
const withProviderSession = createTaskExtended("Provider session task", {
|
|
188
|
+
agentId: agent.id,
|
|
189
|
+
});
|
|
190
|
+
const fresh = createTaskExtended("Fresh task", { agentId: agent.id });
|
|
191
|
+
|
|
192
|
+
startTask(withActiveSession.id);
|
|
193
|
+
startTask(withProviderSession.id);
|
|
194
|
+
startTask(fresh.id);
|
|
195
|
+
|
|
196
|
+
const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
|
197
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id IN (?, ?)", [
|
|
198
|
+
oldTime,
|
|
199
|
+
withActiveSession.id,
|
|
200
|
+
withProviderSession.id,
|
|
201
|
+
]);
|
|
202
|
+
insertActiveSession({
|
|
203
|
+
agentId: agent.id,
|
|
204
|
+
taskId: withActiveSession.id,
|
|
205
|
+
triggerType: "task_assigned",
|
|
206
|
+
});
|
|
207
|
+
updateTaskClaudeSessionId(withProviderSession.id, "claude-live-session");
|
|
208
|
+
|
|
209
|
+
const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
210
|
+
expect(reset.length).toBe(0);
|
|
211
|
+
|
|
212
|
+
expect(getTaskById(withActiveSession.id)?.status).toBe("in_progress");
|
|
213
|
+
expect(getTaskById(withProviderSession.id)?.status).toBe("in_progress");
|
|
214
|
+
expect(getTaskById(fresh.id)?.status).toBe("in_progress");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
160
218
|
describe("getIdleWorkersWithCapacity", () => {
|
|
161
219
|
test("returns idle non-lead agents", () => {
|
|
162
220
|
createAgent({ name: "idle-worker", isLead: false, status: "idle" });
|
|
@@ -306,10 +364,13 @@ describe("Heartbeat Triage", () => {
|
|
|
306
364
|
expect(findings.autoAssigned.length).toBe(1);
|
|
307
365
|
expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
|
|
308
366
|
|
|
309
|
-
// Verify task is
|
|
367
|
+
// Verify task is pending so the worker's normal poll returns task_assigned.
|
|
310
368
|
const task = getTaskById(findings.autoAssigned[0]!.taskId);
|
|
311
|
-
expect(task?.status).toBe("
|
|
369
|
+
expect(task?.status).toBe("pending");
|
|
312
370
|
expect(task?.agentId).toBe(worker.id);
|
|
371
|
+
|
|
372
|
+
const dispatchable = getPendingTaskForAgent(worker.id);
|
|
373
|
+
expect(dispatchable?.id).toBe(task?.id);
|
|
313
374
|
});
|
|
314
375
|
|
|
315
376
|
test("auto-assignment skips lead agents", async () => {
|
|
@@ -340,6 +401,26 @@ describe("Heartbeat Triage", () => {
|
|
|
340
401
|
expect(findings.autoAssigned.length).toBe(0);
|
|
341
402
|
});
|
|
342
403
|
|
|
404
|
+
test("auto-assignment counts pending reservations when assigning pool tasks", async () => {
|
|
405
|
+
const worker = createAgent({ name: "single-slot-worker", isLead: false, status: "idle" });
|
|
406
|
+
createTaskExtended("Pool task 1");
|
|
407
|
+
createTaskExtended("Pool task 2");
|
|
408
|
+
|
|
409
|
+
const findings = await codeLevelTriage();
|
|
410
|
+
expect(findings.autoAssigned.length).toBe(1);
|
|
411
|
+
expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
|
|
412
|
+
|
|
413
|
+
const assigned = getDb()
|
|
414
|
+
.query("SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status = 'pending'")
|
|
415
|
+
.get(worker.id) as { count: number };
|
|
416
|
+
const remaining = getDb()
|
|
417
|
+
.query("SELECT COUNT(*) as count FROM agent_tasks WHERE status = 'unassigned'")
|
|
418
|
+
.get() as { count: number };
|
|
419
|
+
|
|
420
|
+
expect(assigned.count).toBe(1);
|
|
421
|
+
expect(remaining.count).toBe(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
343
424
|
test("fixes worker with busy status but no active tasks", async () => {
|
|
344
425
|
createAgent({ name: "ghost-busy", isLead: false, status: "busy" });
|
|
345
426
|
|
|
@@ -408,7 +489,7 @@ describe("Heartbeat Triage", () => {
|
|
|
408
489
|
|
|
409
490
|
// Verify task was auto-assigned
|
|
410
491
|
const tasks = getDb()
|
|
411
|
-
.query("SELECT * FROM agent_tasks WHERE status = '
|
|
492
|
+
.query("SELECT * FROM agent_tasks WHERE status = 'pending' AND agentId = ?")
|
|
412
493
|
.all(worker.id) as Array<{ id: string }>;
|
|
413
494
|
expect(tasks.length).toBe(1);
|
|
414
495
|
});
|
|
@@ -13,6 +13,7 @@ import { Webhook } from "svix";
|
|
|
13
13
|
const TEST_PORT = 19876;
|
|
14
14
|
const TEST_DB_PATH = `/tmp/test-http-integration-${Date.now()}.sqlite`;
|
|
15
15
|
const BASE = `http://localhost:${TEST_PORT}`;
|
|
16
|
+
const TEST_API_KEY = "test-http-integration-key";
|
|
16
17
|
|
|
17
18
|
let serverProc: Subprocess;
|
|
18
19
|
|
|
@@ -28,6 +29,7 @@ async function api(
|
|
|
28
29
|
): Promise<{ status: number; body: any; ok: boolean }> {
|
|
29
30
|
const headers: Record<string, string> = {
|
|
30
31
|
"Content-Type": "application/json",
|
|
32
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
31
33
|
...opts.headers,
|
|
32
34
|
};
|
|
33
35
|
if (opts.agentId) headers["x-agent-id"] = opts.agentId;
|
|
@@ -106,7 +108,7 @@ beforeAll(async () => {
|
|
|
106
108
|
...process.env,
|
|
107
109
|
PORT: String(TEST_PORT),
|
|
108
110
|
DATABASE_PATH: TEST_DB_PATH,
|
|
109
|
-
API_KEY:
|
|
111
|
+
API_KEY: TEST_API_KEY,
|
|
110
112
|
CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
|
|
111
113
|
// Disable optional integrations
|
|
112
114
|
SLACK_BOT_TOKEN: "",
|
|
@@ -1257,6 +1259,119 @@ describe("Memory", () => {
|
|
|
1257
1259
|
expect(body.memoryIds).toBeDefined();
|
|
1258
1260
|
});
|
|
1259
1261
|
|
|
1262
|
+
test("POST /api/memory/index — gates runner session summaries for automatic task classes", async () => {
|
|
1263
|
+
const automaticCases = [
|
|
1264
|
+
{ label: "schedule source", source: "schedule", taskType: "maintenance", tags: [] },
|
|
1265
|
+
{ label: "system source", source: "system", taskType: "maintenance", tags: [] },
|
|
1266
|
+
{ label: "scheduled tag", source: "mcp", taskType: "maintenance", tags: ["scheduled"] },
|
|
1267
|
+
{ label: "schedule tag", source: "mcp", taskType: "maintenance", tags: ["schedule:test"] },
|
|
1268
|
+
{
|
|
1269
|
+
label: "auto-generated tag",
|
|
1270
|
+
source: "mcp",
|
|
1271
|
+
taskType: "maintenance",
|
|
1272
|
+
tags: ["auto-generated"],
|
|
1273
|
+
},
|
|
1274
|
+
{ label: "heartbeat", source: "mcp", taskType: "heartbeat", tags: [] },
|
|
1275
|
+
{ label: "heartbeat checklist", source: "mcp", taskType: "heartbeat-checklist", tags: [] },
|
|
1276
|
+
{ label: "boot triage", source: "mcp", taskType: "boot-triage", tags: [] },
|
|
1277
|
+
{ label: "health check", source: "mcp", taskType: "health-check", tags: [] },
|
|
1278
|
+
{
|
|
1279
|
+
label: "monitor suffix",
|
|
1280
|
+
source: "mcp",
|
|
1281
|
+
taskType: "claude-code-changelog-monitor",
|
|
1282
|
+
tags: [],
|
|
1283
|
+
},
|
|
1284
|
+
{ label: "digest suffix", source: "mcp", taskType: "daily-blocker-digest", tags: [] },
|
|
1285
|
+
];
|
|
1286
|
+
|
|
1287
|
+
for (const taskCase of automaticCases) {
|
|
1288
|
+
const task = await post("/api/tasks", {
|
|
1289
|
+
agentId: ids.leadAgent,
|
|
1290
|
+
body: {
|
|
1291
|
+
task: `Automatic memory integration test: ${taskCase.label}`,
|
|
1292
|
+
agentId: ids.workerAgent,
|
|
1293
|
+
source: taskCase.source,
|
|
1294
|
+
taskType: taskCase.taskType,
|
|
1295
|
+
tags: taskCase.tags,
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
expect(task.status).toBe(201);
|
|
1299
|
+
|
|
1300
|
+
const { status, body } = await post("/api/memory/index", {
|
|
1301
|
+
agentId: ids.workerAgent,
|
|
1302
|
+
body: {
|
|
1303
|
+
content: `Runner summary that should not persist for ${taskCase.label}.`,
|
|
1304
|
+
name: `automatic-session-summary-${taskCase.label}`,
|
|
1305
|
+
scope: "agent",
|
|
1306
|
+
source: "session_summary",
|
|
1307
|
+
sourceTaskId: task.body.id,
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
expect(status).toBe(202);
|
|
1311
|
+
expect(body.queued).toBe(false);
|
|
1312
|
+
expect(body.memoryIds).toEqual([]);
|
|
1313
|
+
expect(body.skipped).toBe("automatic_task_memory_disabled");
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("POST /api/memory/index — lets runner session summaries opt in for automatic tasks", async () => {
|
|
1318
|
+
const task = await post("/api/tasks", {
|
|
1319
|
+
agentId: ids.leadAgent,
|
|
1320
|
+
body: {
|
|
1321
|
+
task: "Scheduled memory integration opt-in test",
|
|
1322
|
+
agentId: ids.workerAgent,
|
|
1323
|
+
source: "schedule",
|
|
1324
|
+
taskType: "daily-digest",
|
|
1325
|
+
tags: ["schedule:test"],
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
expect(task.status).toBe(201);
|
|
1329
|
+
|
|
1330
|
+
const { status, body } = await post("/api/memory/index", {
|
|
1331
|
+
agentId: ids.workerAgent,
|
|
1332
|
+
body: {
|
|
1333
|
+
content: "Runner summary with reusable learning that explicitly opts into persistence.",
|
|
1334
|
+
name: "automatic-session-summary-opt-in",
|
|
1335
|
+
scope: "agent",
|
|
1336
|
+
source: "session_summary",
|
|
1337
|
+
sourceTaskId: task.body.id,
|
|
1338
|
+
persistMemory: true,
|
|
1339
|
+
},
|
|
1340
|
+
});
|
|
1341
|
+
expect(status).toBe(202);
|
|
1342
|
+
expect(body.queued).toBe(true);
|
|
1343
|
+
expect(body.memoryIds.length).toBeGreaterThan(0);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test("POST /api/memory/index — keeps runner session summaries for manual tasks", async () => {
|
|
1347
|
+
const task = await post("/api/tasks", {
|
|
1348
|
+
agentId: ids.leadAgent,
|
|
1349
|
+
body: {
|
|
1350
|
+
task: "Manual memory integration test",
|
|
1351
|
+
agentId: ids.workerAgent,
|
|
1352
|
+
},
|
|
1353
|
+
});
|
|
1354
|
+
expect(task.status).toBe(201);
|
|
1355
|
+
|
|
1356
|
+
const { status, body } = await post("/api/memory/index", {
|
|
1357
|
+
agentId: ids.workerAgent,
|
|
1358
|
+
body: {
|
|
1359
|
+
content: "Runner summary that should persist for a manual task.",
|
|
1360
|
+
name: "manual-session-summary",
|
|
1361
|
+
scope: "agent",
|
|
1362
|
+
source: "session_summary",
|
|
1363
|
+
sourceTaskId: task.body.id,
|
|
1364
|
+
},
|
|
1365
|
+
});
|
|
1366
|
+
expect(status).toBe(202);
|
|
1367
|
+
expect(body.queued).toBe(true);
|
|
1368
|
+
expect(body.memoryIds.length).toBeGreaterThan(0);
|
|
1369
|
+
|
|
1370
|
+
const memory = await get(`/api/memory/${body.memoryIds[0]}`, { agentId: ids.workerAgent });
|
|
1371
|
+
expect(memory.status).toBe(200);
|
|
1372
|
+
expect(memory.body.memory.sourceTaskId).toBe(task.body.id);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1260
1375
|
test("POST /api/memory/search — missing query returns 400", async () => {
|
|
1261
1376
|
const { status } = await post("/api/memory/search", {
|
|
1262
1377
|
body: {},
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
markKapsoMessageRead,
|
|
4
|
+
sendKapsoReaction,
|
|
5
|
+
sendKapsoText,
|
|
6
|
+
} from "../integrations/kapso/client";
|
|
3
7
|
|
|
4
8
|
const originalFetch = globalThis.fetch;
|
|
5
9
|
|
|
@@ -92,3 +96,72 @@ describe("sendKapsoText", () => {
|
|
|
92
96
|
expect(result.errorMessage).toContain("Invalid API key");
|
|
93
97
|
});
|
|
94
98
|
});
|
|
99
|
+
|
|
100
|
+
describe("Kapso message actions", () => {
|
|
101
|
+
test("markKapsoMessageRead can include the typing indicator", async () => {
|
|
102
|
+
let captured: { url: string; body: unknown } | null = null;
|
|
103
|
+
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
|
104
|
+
captured = { url, body: JSON.parse(init.body as string) };
|
|
105
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
106
|
+
}) as typeof fetch;
|
|
107
|
+
|
|
108
|
+
const result = await markKapsoMessageRead({
|
|
109
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
110
|
+
apiKey: "k",
|
|
111
|
+
phoneNumberId: "p",
|
|
112
|
+
messageId: "wamid.IN",
|
|
113
|
+
typingIndicatorType: "text",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
expect(captured!.url).toBe("https://api.kapso.ai/meta/whatsapp/v24.0/p/messages");
|
|
118
|
+
expect(captured!.body).toEqual({
|
|
119
|
+
messaging_product: "whatsapp",
|
|
120
|
+
status: "read",
|
|
121
|
+
message_id: "wamid.IN",
|
|
122
|
+
typing_indicator: { type: "text" },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("sendKapsoReaction posts the eyes reaction payload", async () => {
|
|
127
|
+
let body: Record<string, unknown> | null = null;
|
|
128
|
+
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
|
129
|
+
body = JSON.parse(init.body as string);
|
|
130
|
+
return new Response(JSON.stringify({ messages: [{ id: "wamid.REACT" }] }), {
|
|
131
|
+
status: 200,
|
|
132
|
+
});
|
|
133
|
+
}) as typeof fetch;
|
|
134
|
+
|
|
135
|
+
const result = await sendKapsoReaction({
|
|
136
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
137
|
+
apiKey: "k",
|
|
138
|
+
phoneNumberId: "p",
|
|
139
|
+
to: "34679077777",
|
|
140
|
+
messageId: "wamid.IN",
|
|
141
|
+
emoji: "👀",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
expect(body).toEqual({
|
|
146
|
+
messaging_product: "whatsapp",
|
|
147
|
+
recipient_type: "individual",
|
|
148
|
+
to: "34679077777",
|
|
149
|
+
type: "reaction",
|
|
150
|
+
reaction: { message_id: "wamid.IN", emoji: "👀" },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("message action errors return structured failures", async () => {
|
|
155
|
+
mockFetch(400, { error: { message: "bad message id" } });
|
|
156
|
+
|
|
157
|
+
const result = await markKapsoMessageRead({
|
|
158
|
+
apiBaseUrl: "https://api.kapso.ai",
|
|
159
|
+
apiKey: "k",
|
|
160
|
+
phoneNumberId: "p",
|
|
161
|
+
messageId: "wamid.BAD",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.ok).toBe(false);
|
|
165
|
+
expect(result.errorMessage).toBe("bad message id");
|
|
166
|
+
});
|
|
167
|
+
});
|