@desplega.ai/agent-swarm 1.88.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.
Files changed (59) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +41 -1
  3. package/package.json +2 -1
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +325 -2
  6. package/src/be/migrations/081_metrics.sql +39 -0
  7. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  8. package/src/be/modelsdev-cache.json +2750 -1431
  9. package/src/be/seed-skills/index.ts +7 -0
  10. package/src/cli.tsx +18 -0
  11. package/src/commands/runner.ts +153 -22
  12. package/src/commands/x.ts +118 -0
  13. package/src/github/handlers.ts +40 -1
  14. package/src/heartbeat/heartbeat.ts +26 -5
  15. package/src/http/active-sessions.ts +32 -1
  16. package/src/http/auth.ts +36 -0
  17. package/src/http/core.ts +20 -16
  18. package/src/http/db-query.ts +20 -0
  19. package/src/http/index.ts +2 -0
  20. package/src/http/metrics.ts +447 -0
  21. package/src/http/operator-actor.ts +9 -0
  22. package/src/http/poll.ts +11 -1
  23. package/src/http/tasks.ts +4 -1
  24. package/src/http/workflows.ts +5 -1
  25. package/src/metrics/version.ts +26 -0
  26. package/src/prompts/base-prompt.ts +8 -0
  27. package/src/prompts/session-templates.ts +23 -0
  28. package/src/providers/opencode-adapter.ts +22 -6
  29. package/src/server.ts +10 -1
  30. package/src/tests/base-prompt.test.ts +35 -0
  31. package/src/tests/budget-claim-gate.test.ts +26 -0
  32. package/src/tests/core-auth.test.ts +8 -1
  33. package/src/tests/events-http.test.ts +6 -2
  34. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  35. package/src/tests/heartbeat.test.ts +84 -3
  36. package/src/tests/http-api-integration.test.ts +3 -1
  37. package/src/tests/metrics-http.test.ts +247 -0
  38. package/src/tests/opencode-adapter.test.ts +90 -30
  39. package/src/tests/runner-repo-autostash.test.ts +117 -0
  40. package/src/tests/runner-requester-profile.test.ts +25 -0
  41. package/src/tests/runner-skills-refresh.test.ts +1 -1
  42. package/src/tests/swarm-x-tool.test.ts +90 -0
  43. package/src/tests/system-default-skills.test.ts +3 -0
  44. package/src/tests/ui-logs-parser.test.ts +271 -0
  45. package/src/tests/user-token-rest-auth.test.ts +129 -0
  46. package/src/tests/workflow-async-v2.test.ts +23 -0
  47. package/src/tests/x-composio.test.ts +122 -0
  48. package/src/tools/create-metric.ts +191 -0
  49. package/src/tools/swarm-x.ts +116 -0
  50. package/src/tools/tool-config.ts +6 -0
  51. package/src/types.ts +120 -0
  52. package/src/utils/request-auth-context.ts +28 -0
  53. package/src/utils/skills-refresh.ts +2 -2
  54. package/src/workflows/engine.ts +24 -2
  55. package/src/workflows/executors/agent-task.ts +2 -0
  56. package/src/x/composio.ts +295 -0
  57. package/templates/skills/attio-interaction/SKILL.md +279 -0
  58. package/templates/skills/attio-interaction/config.json +14 -0
  59. package/templates/skills/attio-interaction/content.md +272 -0
@@ -0,0 +1,117 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { ensureRepoForTask } from "../commands/runner";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ let tempRoot = "";
12
+
13
+ async function git(cwd: string, args: string[]): Promise<string> {
14
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args]);
15
+ return stdout;
16
+ }
17
+
18
+ async function gitRaw(args: string[]): Promise<void> {
19
+ await execFileAsync("git", args);
20
+ }
21
+
22
+ async function commitAll(cwd: string, message: string): Promise<void> {
23
+ await git(cwd, ["add", "."]);
24
+ await git(cwd, ["commit", "-m", message]);
25
+ }
26
+
27
+ async function configureIdentity(cwd: string): Promise<void> {
28
+ await git(cwd, ["config", "user.email", "test@example.com"]);
29
+ await git(cwd, ["config", "user.name", "Test User"]);
30
+ }
31
+
32
+ describe("ensureRepoForTask auto-stash refresh", () => {
33
+ beforeEach(async () => {
34
+ tempRoot = await mkdtemp(join(tmpdir(), "swarm-runner-autostash-"));
35
+ });
36
+
37
+ afterEach(async () => {
38
+ if (tempRoot) {
39
+ await rm(tempRoot, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test("stashes dirty work with a swarm-autostash name before refreshing from origin", async () => {
44
+ const remotePath = join(tempRoot, "remote.git");
45
+ const upstreamPath = join(tempRoot, "upstream");
46
+ const clonePath = join(tempRoot, "clone");
47
+
48
+ await gitRaw(["init", "--bare", remotePath]);
49
+ await mkdir(upstreamPath);
50
+ await git(upstreamPath, ["init", "-b", "main"]);
51
+ await configureIdentity(upstreamPath);
52
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
53
+ await commitAll(upstreamPath, "initial commit");
54
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
55
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
56
+
57
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
58
+ await configureIdentity(clonePath);
59
+ await writeFile(join(clonePath, "README.md"), "local dirty change\n");
60
+ await writeFile(join(clonePath, "untracked.txt"), "local untracked\n");
61
+
62
+ await writeFile(join(upstreamPath, "remote.txt"), "remote change\n");
63
+ await commitAll(upstreamPath, "remote update");
64
+ await git(upstreamPath, ["push", "origin", "main"]);
65
+
66
+ const result = await ensureRepoForTask(
67
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
68
+ "test",
69
+ );
70
+
71
+ expect(result.warning).toBeNull();
72
+ expect(result.autoStashes).toHaveLength(1);
73
+ expect(result.autoStashes[0]?.ref).toMatch(/^stash@\{\d+\}$/);
74
+ expect(result.autoStashes[0]?.message).toContain("swarm-autostash main ");
75
+ expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote change\n");
76
+ expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
77
+
78
+ const stashList = await git(clonePath, ["stash", "list"]);
79
+ expect(stashList).toContain("swarm-autostash main ");
80
+ expect(stashList).toContain("On main:");
81
+ });
82
+
83
+ test("merges a clean divergent checkout with origin without hard reset", async () => {
84
+ const remotePath = join(tempRoot, "remote.git");
85
+ const upstreamPath = join(tempRoot, "upstream");
86
+ const clonePath = join(tempRoot, "clone");
87
+
88
+ await gitRaw(["init", "--bare", remotePath]);
89
+ await mkdir(upstreamPath);
90
+ await git(upstreamPath, ["init", "-b", "main"]);
91
+ await configureIdentity(upstreamPath);
92
+ await writeFile(join(upstreamPath, "README.md"), "initial\n");
93
+ await commitAll(upstreamPath, "initial commit");
94
+ await git(upstreamPath, ["remote", "add", "origin", remotePath]);
95
+ await git(upstreamPath, ["push", "-u", "origin", "main"]);
96
+
97
+ await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
98
+ await configureIdentity(clonePath);
99
+ await writeFile(join(clonePath, "local.txt"), "local commit\n");
100
+ await commitAll(clonePath, "local commit");
101
+
102
+ await writeFile(join(upstreamPath, "remote.txt"), "remote commit\n");
103
+ await commitAll(upstreamPath, "remote commit");
104
+ await git(upstreamPath, ["push", "origin", "main"]);
105
+
106
+ const result = await ensureRepoForTask(
107
+ { url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
108
+ "test",
109
+ );
110
+
111
+ expect(result.warning).toBeNull();
112
+ expect(result.autoStashes).toEqual([]);
113
+ expect(await readFile(join(clonePath, "local.txt"), "utf8")).toBe("local commit\n");
114
+ expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote commit\n");
115
+ expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
116
+ });
117
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildRequesterProfilePrompt } from "../commands/runner";
3
+
4
+ describe("runner requester profile prompt", () => {
5
+ test("omits requester profile when no role or notes are set", async () => {
6
+ await expect(
7
+ buildRequesterProfilePrompt({ name: "Taras", email: "t@example.com" }),
8
+ ).resolves.toBe("");
9
+ });
10
+
11
+ test("formats requester role and free-text notes", async () => {
12
+ const prompt = await buildRequesterProfilePrompt({
13
+ name: "Taras",
14
+ email: "t@example.com",
15
+ role: "CEO",
16
+ notes: "Lead with the answer; keep updates terse.",
17
+ });
18
+
19
+ expect(prompt).toContain("## Requester Profile");
20
+ expect(prompt).toContain("This task was requested by Taras (CEO).");
21
+ expect(prompt).toContain("Their stated notes for how you should respond and act:");
22
+ expect(prompt).toContain("Lead with the answer; keep updates terse.");
23
+ expect(prompt).toContain("where it doesn't conflict with correctness or your operating rules");
24
+ });
25
+ });
@@ -83,7 +83,7 @@ describe("refreshSkillsIfChanged", () => {
83
83
  function makeCtx(): SkillsRefreshContext {
84
84
  return {
85
85
  apiUrl: baseUrl,
86
- swarmUrl: baseUrl,
86
+ swarmUrl: "app.agent-swarm.dev",
87
87
  apiKey: "test-key",
88
88
  agentId: "agent-1",
89
89
  role: "worker",
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { registerSwarmXTool } from "../tools/swarm-x";
4
+ import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
5
+
6
+ type RegisteredTool = {
7
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
8
+ };
9
+
10
+ const originalFetch = globalThis.fetch;
11
+ const originalComposioKey = process.env.COMPOSIO_API_KEY;
12
+
13
+ function jsonResponse(body: unknown, status = 200): Response {
14
+ return new Response(JSON.stringify(body), {
15
+ status,
16
+ headers: { "Content-Type": "application/json" },
17
+ });
18
+ }
19
+
20
+ function buildTool() {
21
+ const server = new McpServer({ name: "swarm-x-test", version: "1.0.0" });
22
+ registerSwarmXTool(server);
23
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
24
+ ._registeredTools;
25
+ const tool = registered.swarm_x;
26
+ if (!tool) throw new Error("swarm_x tool not registered");
27
+ return tool;
28
+ }
29
+
30
+ afterEach(() => {
31
+ globalThis.fetch = originalFetch;
32
+ if (originalComposioKey === undefined) delete process.env.COMPOSIO_API_KEY;
33
+ else process.env.COMPOSIO_API_KEY = originalComposioKey;
34
+ clearVolatileSecretsForTesting();
35
+ });
36
+
37
+ describe("swarm_x MCP tool", () => {
38
+ test("routes composio requests with server-side auth and scrubbed output", async () => {
39
+ process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
40
+ const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => {
41
+ expect(String(url)).toBe("https://backend.composio.dev/api/v3.1/tools?limit=1");
42
+ expect(init?.method).toBe("GET");
43
+ expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("ck_tool_secret_value");
44
+ return jsonResponse({ ok: true, token: "ck_tool_secret_value" });
45
+ });
46
+ globalThis.fetch = fetchMock;
47
+
48
+ const tool = buildTool();
49
+ const result = (await tool.handler(
50
+ {
51
+ target: "composio",
52
+ method: "GET",
53
+ path: "/tools",
54
+ query: { limit: 1 },
55
+ },
56
+ { sessionId: "s", requestInfo: { headers: {} } },
57
+ )) as {
58
+ isError?: boolean;
59
+ structuredContent: { ok: boolean; response: unknown; responseText: string };
60
+ };
61
+
62
+ expect(fetchMock).toHaveBeenCalledTimes(1);
63
+ expect(result.isError).toBe(false);
64
+ expect(result.structuredContent.ok).toBe(true);
65
+ expect(JSON.stringify(result.structuredContent.response)).toContain(
66
+ "[REDACTED:COMPOSIO_API_KEY]",
67
+ );
68
+ expect(result.structuredContent.responseText).not.toContain("ck_tool_secret_value");
69
+ });
70
+
71
+ test("rejects absolute composio paths", async () => {
72
+ process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
73
+ const fetchMock = mock(async () => jsonResponse({ ok: true }));
74
+ globalThis.fetch = fetchMock;
75
+
76
+ const tool = buildTool();
77
+ const result = (await tool.handler(
78
+ {
79
+ target: "composio",
80
+ method: "GET",
81
+ path: "https://evil.example/tools",
82
+ },
83
+ { sessionId: "s", requestInfo: { headers: {} } },
84
+ )) as { isError?: boolean; structuredContent: { message: string } };
85
+
86
+ expect(result.isError).toBe(true);
87
+ expect(result.structuredContent.message).toContain("endpoint must be a Composio API path");
88
+ expect(fetchMock).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -36,7 +36,9 @@ describe("system-default skills", () => {
36
36
  const skills = loadSeedSkills();
37
37
  const names = skills.map((skill) => skill.name);
38
38
 
39
+ expect(names).toContain("attio-interaction");
39
40
  expect(names).toContain("swarm-scripts");
41
+ expect(skills.find((skill) => skill.name === "attio-interaction")?.systemDefault).toBe(true);
40
42
  expect(skills.find((skill) => skill.name === "swarm-scripts")?.systemDefault).toBe(true);
41
43
  expect(skills.find((skill) => skill.name === "kv-storage")?.systemDefault).toBe(true);
42
44
  expect(skills.find((skill) => skill.name === "pages")?.systemDefault).toBe(true);
@@ -45,6 +47,7 @@ describe("system-default skills", () => {
45
47
  expect(result.failed).toEqual([]);
46
48
 
47
49
  const defaults = getSystemDefaultSkills().map((skill) => skill.name);
50
+ expect(defaults).toContain("attio-interaction");
48
51
  expect(defaults).toContain("swarm-scripts");
49
52
  expect(defaults).toContain("kv-storage");
50
53
  expect(defaults).toContain("pages");
@@ -0,0 +1,271 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ normalizeSessionLogs,
4
+ parseSessionLogs,
5
+ type SessionLogRecord,
6
+ unwrapResult,
7
+ } from "../../ui/src/logs-parser";
8
+
9
+ function log(
10
+ id: string,
11
+ cli: string,
12
+ lineNumber: number,
13
+ content: unknown,
14
+ createdAt = "2026-06-01T10:00:00.000Z",
15
+ ): SessionLogRecord {
16
+ return {
17
+ id,
18
+ taskId: "task-1",
19
+ sessionId: "session-1",
20
+ iteration: 1,
21
+ cli,
22
+ content: typeof content === "string" ? content : JSON.stringify(content),
23
+ lineNumber,
24
+ createdAt,
25
+ };
26
+ }
27
+
28
+ describe("ui logs parser", () => {
29
+ test("orders opencode deltas before reassembling streamed text", () => {
30
+ const result = normalizeSessionLogs([
31
+ log("delta-2", "opencode", 2, {
32
+ type: "message.part.delta",
33
+ properties: { partID: "part-1", delta: "world" },
34
+ }),
35
+ log("updated", "opencode", 3, {
36
+ type: "message.part.updated",
37
+ properties: { part: { id: "part-1", type: "text" } },
38
+ }),
39
+ log("delta-1", "opencode", 1, {
40
+ type: "message.part.delta",
41
+ properties: { partID: "part-1", delta: "Hello " },
42
+ }),
43
+ ]);
44
+
45
+ expect(result.gate).toEqual({ total: 3, ok: 3, bad: 0, passed: true });
46
+ expect(result.items).toHaveLength(1);
47
+ expect(result.items[0]?.kind).toBe("text");
48
+ expect(result.items[0]?.text).toBe("Hello world");
49
+ expect(result.items[0]?.recId).toBe("delta-1");
50
+ });
51
+
52
+ test("pairs codex started and completed tool events by item id", () => {
53
+ const result = normalizeSessionLogs([
54
+ log("start", "codex", 1, {
55
+ type: "item.started",
56
+ item: { id: "item-1", type: "command_execution", command: "pwd" },
57
+ }),
58
+ log("done", "codex", 2, {
59
+ type: "item.completed",
60
+ item: {
61
+ id: "item-1",
62
+ type: "command_execution",
63
+ aggregated_output: "/tmp\n",
64
+ exit_code: 0,
65
+ },
66
+ }),
67
+ ]);
68
+
69
+ expect(result.items.map((item) => item.kind)).toEqual(["tool_call", "tool_result"]);
70
+ expect(result.pairing.paired).toBe(1);
71
+ expect(result.pairing.orphanCalls).toEqual([]);
72
+ expect(result.pairing.orphanResults).toEqual([]);
73
+ });
74
+
75
+ test("normalizes claude-managed raw SSE events without unknown noise", () => {
76
+ const result = normalizeSessionLogs([
77
+ log("status", "claude-managed", 1, {
78
+ type: "session.status_running",
79
+ id: "evt-running",
80
+ }),
81
+ log("message", "claude-managed", 2, {
82
+ type: "agent.message",
83
+ id: "evt-message",
84
+ content: [{ type: "text", text: "Hello from managed agent" }],
85
+ }),
86
+ log("tool", "claude-managed", 3, {
87
+ type: "agent.tool_use",
88
+ id: "tool-1",
89
+ name: "read_file",
90
+ input: { path: "/etc/hosts" },
91
+ }),
92
+ log("result", "claude-managed", 4, {
93
+ type: "agent.tool_result",
94
+ id: "tool-result-1",
95
+ tool_use_id: "tool-1",
96
+ content: [{ type: "text", text: "127.0.0.1 localhost" }],
97
+ is_error: false,
98
+ }),
99
+ ]);
100
+
101
+ expect(result.items.map((item) => item.kind)).toEqual([
102
+ "lifecycle",
103
+ "text",
104
+ "tool_call",
105
+ "tool_result",
106
+ ]);
107
+ expect(result.items.some((item) => item.kind === "unknown")).toBe(false);
108
+ expect(result.pairing.paired).toBe(1);
109
+ expect(result.pairing.orphanCalls).toEqual([]);
110
+ expect(result.pairing.orphanResults).toEqual([]);
111
+ });
112
+
113
+ test("keeps parse errors visible in the compatibility message output", () => {
114
+ const messages = parseSessionLogs([log("bad", "claude", 1, "{not-json")]);
115
+ expect(messages).toHaveLength(1);
116
+ expect(messages[0]?.role).toBe("system");
117
+ expect(messages[0]?.content[0]).toEqual({
118
+ type: "provider_meta",
119
+ kind: "parse_error",
120
+ provider: "claude",
121
+ data: { raw: "{not-json" },
122
+ });
123
+ });
124
+
125
+ test("classifies claude runtime noise as internal or helper metadata", () => {
126
+ const messages = parseSessionLogs([
127
+ log("rate", "claude", 1, {
128
+ type: "rate_limit_event",
129
+ rate_limit_info: { status: "rejected", resetsAt: 1779202200 },
130
+ }),
131
+ log("think", "claude", 2, {
132
+ type: "system",
133
+ subtype: "thinking_tokens",
134
+ estimated_tokens: 150,
135
+ estimated_tokens_delta: 100,
136
+ }),
137
+ log("hook", "claude", 3, {
138
+ type: "system",
139
+ subtype: "hook_response",
140
+ hook_id: "hook-1",
141
+ hook_event: "SessionStart",
142
+ outcome: "success",
143
+ }),
144
+ ]);
145
+
146
+ expect(messages.map((message) => message.content[0])).toEqual([
147
+ expect.objectContaining({
148
+ type: "provider_meta",
149
+ kind: "internal",
150
+ data: expect.objectContaining({ internalType: "rate_limit" }),
151
+ }),
152
+ expect.objectContaining({
153
+ type: "provider_meta",
154
+ kind: "helper",
155
+ data: expect.objectContaining({ helperType: "thinking_tokens" }),
156
+ }),
157
+ expect.objectContaining({
158
+ type: "provider_meta",
159
+ kind: "internal",
160
+ data: expect.objectContaining({ internalType: "hook" }),
161
+ }),
162
+ ]);
163
+ });
164
+
165
+ test("classifies codex and opencode lifecycle rows for shared rendering", () => {
166
+ const codex = parseSessionLogs([
167
+ log("turn", "codex", 1, {
168
+ type: "turn.completed",
169
+ usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 10 },
170
+ }),
171
+ ]);
172
+ const opencode = parseSessionLogs([
173
+ log("context", "opencode", 1, {
174
+ type: "context_usage",
175
+ contextUsedTokens: 25_000,
176
+ contextTotalTokens: 200_000,
177
+ contextPercent: 12.5,
178
+ }),
179
+ log("session", "opencode", 2, {
180
+ type: "session_init",
181
+ sessionId: "ses_1",
182
+ provider: "opencode",
183
+ }),
184
+ log("heartbeat", "opencode", 3, { type: "server.heartbeat", properties: {} }),
185
+ log("connected", "opencode", 4, { type: "server.connected", properties: {} }),
186
+ log("result", "opencode", 5, {
187
+ type: "result",
188
+ cost: { totalCostUsd: 0.12, inputTokens: 100, outputTokens: 20 },
189
+ isError: false,
190
+ }),
191
+ ]);
192
+
193
+ expect(codex[0]?.content[0]).toEqual(
194
+ expect.objectContaining({
195
+ type: "provider_meta",
196
+ kind: "helper",
197
+ data: expect.objectContaining({ helperType: "turn_usage" }),
198
+ }),
199
+ );
200
+ expect(opencode.map((message) => message.content[0])).toEqual([
201
+ expect.objectContaining({
202
+ type: "provider_meta",
203
+ kind: "helper",
204
+ data: expect.objectContaining({ helperType: "context_usage" }),
205
+ }),
206
+ expect.objectContaining({
207
+ type: "provider_meta",
208
+ kind: "internal",
209
+ data: expect.objectContaining({ internalType: "runtime" }),
210
+ }),
211
+ expect.objectContaining({ type: "provider_meta", kind: "result" }),
212
+ ]);
213
+ });
214
+
215
+ test("keeps devin provider meta and transcript messages on the generic path", () => {
216
+ const messages = parseSessionLogs([
217
+ log("status", "devin", 1, {
218
+ type: "system",
219
+ message: { role: "system", content: "" },
220
+ provider_meta: { provider: "devin", kind: "status", status: "running" },
221
+ }),
222
+ log("message", "devin", 2, {
223
+ type: "assistant",
224
+ message: { role: "assistant", content: "Devin update" },
225
+ }),
226
+ ]);
227
+
228
+ expect(messages.map((message) => message.content[0])).toEqual([
229
+ expect.objectContaining({
230
+ type: "provider_meta",
231
+ kind: "status",
232
+ provider: "devin",
233
+ data: expect.objectContaining({ status: "running" }),
234
+ }),
235
+ { type: "text", text: "Devin update" },
236
+ ]);
237
+ });
238
+
239
+ test("unwraps prose followed by embedded JSON", () => {
240
+ expect(unwrapResult('Created file\n\n{"ok":true,"path":"a.ts"}')).toEqual({
241
+ prose: "Created file",
242
+ json: { ok: true, path: "a.ts" },
243
+ });
244
+ });
245
+
246
+ test("unwraps pi tool result content text wrappers", () => {
247
+ const messages = parseSessionLogs([
248
+ log("pi-result", "pi", 1, {
249
+ type: "assistant",
250
+ message: {
251
+ content: [
252
+ {
253
+ type: "tool_result",
254
+ tool_use_id: "functions.memory-get:1",
255
+ content: JSON.stringify({
256
+ content: [{ type: "text", text: 'Memory retrieved.\n\n{"ok":true}' }],
257
+ }),
258
+ },
259
+ ],
260
+ },
261
+ }),
262
+ ]);
263
+
264
+ expect(messages[0]?.content[0]).toEqual(
265
+ expect.objectContaining({
266
+ type: "tool_result",
267
+ content: 'Memory retrieved.\n\n{\n "ok": true\n}',
268
+ }),
269
+ );
270
+ });
271
+ });
@@ -0,0 +1,129 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlinkSync } from "node:fs";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import { closeDb, createAgent, createUser, getDb, initDb } from "../be/db";
10
+ import { type IdentityActor, mintToken, revokeToken } from "../be/users";
11
+ import { handleCore } from "../http/core";
12
+ import { handleTasks } from "../http/tasks";
13
+ import { getPathSegments, parseQueryParams } from "../http/utils";
14
+
15
+ const TEST_DB_PATH = "./test-user-token-rest-auth.sqlite";
16
+ const API_KEY = "test-api-key";
17
+ const ACTOR: IdentityActor = { kind: "operator", id: "op:test" };
18
+
19
+ function createTestServer(): Server {
20
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
21
+ const pathSegments = getPathSegments(req.url || "");
22
+ const queryParams = parseQueryParams(req.url || "");
23
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
24
+
25
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
26
+ if (await handleTasks(req, res, pathSegments, queryParams, myAgentId)) return;
27
+
28
+ res.writeHead(404);
29
+ res.end("Not Found");
30
+ });
31
+ }
32
+
33
+ async function listen(server: Server): Promise<number> {
34
+ await new Promise<void>((resolve) => server.listen(0, resolve));
35
+ const addr = server.address();
36
+ if (!addr || typeof addr === "string") throw new Error("no port");
37
+ return addr.port;
38
+ }
39
+
40
+ function cleanupDb() {
41
+ for (const suffix of ["", "-wal", "-shm"]) {
42
+ try {
43
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
44
+ } catch {}
45
+ }
46
+ }
47
+
48
+ describe("normal REST API user-bound token auth", () => {
49
+ let server: Server;
50
+ let port: number;
51
+
52
+ beforeAll(async () => {
53
+ cleanupDb();
54
+ initDb(TEST_DB_PATH);
55
+ createAgent({ name: "Lead", isLead: true, status: "idle" });
56
+ server = createTestServer();
57
+ port = await listen(server);
58
+ });
59
+
60
+ afterAll(() => {
61
+ server.close();
62
+ closeDb();
63
+ cleanupDb();
64
+ });
65
+
66
+ test("POST /api/tasks accepts active user token and forces requester/audit user", async () => {
67
+ const user = createUser({ name: "Token REST User" });
68
+ const other = createUser({ name: "Other User" });
69
+ const { plaintext } = mintToken(user.id, "rest", ACTOR);
70
+
71
+ const res = await fetch(`http://localhost:${port}/api/tasks`, {
72
+ method: "POST",
73
+ headers: {
74
+ Authorization: `Bearer ${plaintext}`,
75
+ "Content-Type": "application/json",
76
+ },
77
+ body: JSON.stringify({
78
+ task: "created through user token",
79
+ requestedByUserId: other.id,
80
+ }),
81
+ });
82
+
83
+ expect(res.status).toBe(201);
84
+ const body = (await res.json()) as { id: string; requestedByUserId?: string };
85
+ expect(body.requestedByUserId).toBe(user.id);
86
+
87
+ const row = getDb()
88
+ .prepare<
89
+ { requestedByUserId: string | null; created_by: string | null; updated_by: string | null },
90
+ string
91
+ >("SELECT requestedByUserId, created_by, updated_by FROM agent_tasks WHERE id = ?")
92
+ .get(body.id);
93
+ expect(row?.requestedByUserId).toBe(user.id);
94
+ expect(row?.created_by).toBe(user.id);
95
+ expect(row?.updated_by).toBe(user.id);
96
+ });
97
+
98
+ test("global API key still creates unattributed tasks by default", async () => {
99
+ const res = await fetch(`http://localhost:${port}/api/tasks`, {
100
+ method: "POST",
101
+ headers: {
102
+ Authorization: `Bearer ${API_KEY}`,
103
+ "Content-Type": "application/json",
104
+ },
105
+ body: JSON.stringify({ task: "created through global key" }),
106
+ });
107
+
108
+ expect(res.status).toBe(201);
109
+ const body = (await res.json()) as { id: string; requestedByUserId?: string };
110
+ expect(body.requestedByUserId).toBeUndefined();
111
+ });
112
+
113
+ test("revoked user token is unauthorized for normal API", async () => {
114
+ const user = createUser({ name: "Revoked REST User" });
115
+ const { tokenId, plaintext } = mintToken(user.id, "revoked", ACTOR);
116
+ revokeToken(tokenId, ACTOR);
117
+
118
+ const res = await fetch(`http://localhost:${port}/api/tasks`, {
119
+ method: "POST",
120
+ headers: {
121
+ Authorization: `Bearer ${plaintext}`,
122
+ "Content-Type": "application/json",
123
+ },
124
+ body: JSON.stringify({ task: "should not be created" }),
125
+ });
126
+
127
+ expect(res.status).toBe(401);
128
+ });
129
+ });