@desplega.ai/agent-swarm 1.79.4 → 1.80.1

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 (130) hide show
  1. package/openapi.json +496 -32
  2. package/package.json +14 -6
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +102 -31
  5. package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
  6. package/src/be/migrations/064_scripts.sql +39 -0
  7. package/src/be/migrations/065_script_embeddings.sql +7 -0
  8. package/src/be/pricing-normalize.ts +81 -0
  9. package/src/be/scripts/db.ts +391 -0
  10. package/src/be/scripts/embeddings.ts +231 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +193 -0
  13. package/src/be/seed-pricing.ts +293 -0
  14. package/src/cli.tsx +22 -5
  15. package/src/commands/artifact.ts +3 -2
  16. package/src/commands/claude-managed-setup.ts +21 -4
  17. package/src/commands/codex-login.ts +5 -3
  18. package/src/commands/onboard.tsx +2 -1
  19. package/src/commands/runner.ts +663 -246
  20. package/src/commands/setup.tsx +5 -3
  21. package/src/hooks/hook.ts +4 -3
  22. package/src/http/context.ts +6 -2
  23. package/src/http/index.ts +126 -68
  24. package/src/http/memory.ts +28 -0
  25. package/src/http/openapi.ts +1 -0
  26. package/src/http/page-proxy.ts +2 -1
  27. package/src/http/route-def.ts +1 -0
  28. package/src/http/schedules.ts +37 -0
  29. package/src/http/scripts.ts +381 -0
  30. package/src/http/session-data.ts +74 -23
  31. package/src/linear/outbound.ts +9 -2
  32. package/src/otel-impl.ts +200 -0
  33. package/src/otel.ts +132 -0
  34. package/src/providers/claude-adapter.ts +52 -6
  35. package/src/providers/claude-managed-adapter.ts +43 -17
  36. package/src/providers/claude-managed-pricing.ts +34 -0
  37. package/src/providers/codex-adapter.ts +38 -27
  38. package/src/providers/codex-models.ts +22 -3
  39. package/src/providers/devin-adapter.ts +11 -0
  40. package/src/providers/opencode-adapter.ts +31 -7
  41. package/src/providers/pi-mono-adapter.ts +39 -7
  42. package/src/providers/pricing-sources.md +52 -0
  43. package/src/providers/swarm-events-shared.ts +8 -4
  44. package/src/providers/types.ts +33 -10
  45. package/src/scripts-runtime/ctx.ts +23 -0
  46. package/src/scripts-runtime/eval-harness.ts +39 -0
  47. package/src/scripts-runtime/executors/native.ts +229 -0
  48. package/src/scripts-runtime/executors/registry.ts +16 -0
  49. package/src/scripts-runtime/executors/types.ts +63 -0
  50. package/src/scripts-runtime/extract-signature.ts +81 -0
  51. package/src/scripts-runtime/import-allowlist.ts +109 -0
  52. package/src/scripts-runtime/loader.ts +96 -0
  53. package/src/scripts-runtime/redacted.ts +48 -0
  54. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  55. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  56. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  57. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  58. package/src/scripts-runtime/stdlib/index.ts +16 -0
  59. package/src/scripts-runtime/stdlib/table.ts +17 -0
  60. package/src/scripts-runtime/swarm-config.ts +35 -0
  61. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  62. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  63. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  64. package/src/server.ts +18 -0
  65. package/src/tests/api-key.test.ts +33 -0
  66. package/src/tests/claude-managed-adapter.test.ts +17 -3
  67. package/src/tests/claude-managed-setup.test.ts +10 -1
  68. package/src/tests/codex-adapter.test.ts +20 -19
  69. package/src/tests/codex-login.test.ts +1 -1
  70. package/src/tests/context-snapshot.test.ts +2 -2
  71. package/src/tests/context-window.test.ts +65 -1
  72. package/src/tests/devin-adapter.test.ts +2 -0
  73. package/src/tests/http/context-routes.test.ts +161 -0
  74. package/src/tests/linear-outbound-sync.test.ts +109 -0
  75. package/src/tests/mcp-tools.test.ts +69 -0
  76. package/src/tests/migration-063-schema-relax.test.ts +109 -0
  77. package/src/tests/opencode-adapter.test.ts +146 -1
  78. package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
  79. package/src/tests/pages-view-count.test.ts +30 -5
  80. package/src/tests/providers/codex-cost.test.ts +18 -0
  81. package/src/tests/providers/opencode-cost.test.ts +74 -0
  82. package/src/tests/providers/pi-cost.test.ts +128 -0
  83. package/src/tests/redacted.test.ts +29 -0
  84. package/src/tests/runner-tool-spans.test.ts +268 -0
  85. package/src/tests/script-executor-conformance.test.ts +142 -0
  86. package/src/tests/script-executor-registry.test.ts +17 -0
  87. package/src/tests/scripts-db.test.ts +329 -0
  88. package/src/tests/scripts-embeddings.test.ts +291 -0
  89. package/src/tests/scripts-extract-signature.test.ts +47 -0
  90. package/src/tests/scripts-http.test.ts +350 -0
  91. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  92. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  93. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  94. package/src/tests/scripts-runtime.test.ts +289 -0
  95. package/src/tests/sdk-allowlist.test.ts +59 -0
  96. package/src/tests/secret-scrubber.test.ts +54 -1
  97. package/src/tests/session-costs-codex-recompute.test.ts +35 -22
  98. package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
  99. package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
  100. package/src/tests/store-progress-cost.test.ts +6 -1
  101. package/src/tests/swarm-config.test.ts +38 -0
  102. package/src/tests/tool-annotations.test.ts +2 -2
  103. package/src/tests/tool-call-progress.test.ts +30 -0
  104. package/src/tests/workflow-e2e.test.ts +218 -0
  105. package/src/tests/workflow-executors.test.ts +32 -2
  106. package/src/tests/workflow-input-redaction.test.ts +232 -0
  107. package/src/tests/workflow-swarm-script.test.ts +273 -0
  108. package/src/tools/memory-rate.ts +2 -1
  109. package/src/tools/script-common.ts +88 -0
  110. package/src/tools/script-delete.ts +35 -0
  111. package/src/tools/script-query-types.ts +37 -0
  112. package/src/tools/script-run.ts +43 -0
  113. package/src/tools/script-search.ts +32 -0
  114. package/src/tools/script-upsert.ts +43 -0
  115. package/src/tools/store-progress.ts +16 -60
  116. package/src/tools/tool-config.ts +7 -0
  117. package/src/tools/utils.ts +65 -12
  118. package/src/types.ts +122 -10
  119. package/src/utils/api-key.ts +28 -0
  120. package/src/utils/context-window.ts +104 -4
  121. package/src/utils/page-session.ts +8 -6
  122. package/src/utils/secret-scrubber.ts +29 -1
  123. package/src/workflows/engine.ts +12 -4
  124. package/src/workflows/executors/index.ts +1 -0
  125. package/src/workflows/executors/registry.ts +2 -0
  126. package/src/workflows/executors/script.ts +12 -1
  127. package/src/workflows/executors/swarm-script.ts +170 -0
  128. package/src/workflows/input.ts +65 -0
  129. package/src/workflows/recovery.ts +31 -3
  130. package/src/workflows/resume.ts +43 -5
@@ -319,11 +319,14 @@ describe("ClaudeManagedAdapter (Phase 3) — session lifecycle", () => {
319
319
  }
320
320
 
321
321
  // context_usage emitted on span.model_request_end.
322
+ // Phase 5 / Phase 9 unified formula = input + cache_read + cache_write + output.
322
323
  const ctx = emitted.find((e) => e.type === "context_usage");
323
324
  expect(ctx).toBeDefined();
324
325
  if (ctx && ctx.type === "context_usage") {
325
- expect(ctx.contextUsedTokens).toBe(150); // 100 input + 50 output
326
+ expect(ctx.contextUsedTokens).toBe(165); // 100 + 10 + 5 + 50
326
327
  expect(ctx.outputTokens).toBe(50);
328
+ // Phase 9: every snapshot carries the formula tag.
329
+ expect(ctx.contextFormula).toBe("input-cache-output");
327
330
  }
328
331
 
329
332
  // result emitted with accumulated cost. Phase 3 leaves totalCostUsd at 0
@@ -345,6 +348,8 @@ describe("ClaudeManagedAdapter (Phase 3) — session lifecycle", () => {
345
348
  expect(resultEvent.cost.totalCostUsd).toBeGreaterThanOrEqual(0);
346
349
  expect(Number.isFinite(resultEvent.cost.totalCostUsd)).toBe(true);
347
350
  expect(resultEvent.output).toBe("Hello from managed agent");
351
+ // Phase 3 — provider tag is required so the API recompute path engages.
352
+ expect(resultEvent.cost.provider).toBe("claude-managed");
348
353
  }
349
354
 
350
355
  // ProviderResult.
@@ -644,17 +649,24 @@ describe("ClaudeManagedAdapter (Phase 4) — repo provisioning + cost data", ()
644
649
  process.env.ANTHROPIC_API_KEY = "sk-test";
645
650
  process.env.MANAGED_AGENT_ID = "agent_x";
646
651
  process.env.MANAGED_ENVIRONMENT_ID = "env_x";
652
+ // Defensive: vault env vars may leak in from the host .env (Bun auto-loads
653
+ // it); each vault-related test sets exactly what it asserts on.
654
+ delete process.env.MANAGED_GITHUB_TOKEN;
655
+ delete process.env.MANAGED_GITHUB_VAULT_ID;
656
+ delete process.env.MANAGED_MCP_VAULT_ID;
647
657
  });
648
658
 
649
659
  afterAll(() => {
650
660
  rmSync(tmpLogDir, { recursive: true, force: true });
651
661
  delete process.env.MANAGED_GITHUB_TOKEN;
652
662
  delete process.env.MANAGED_GITHUB_VAULT_ID;
663
+ delete process.env.MANAGED_MCP_VAULT_ID;
653
664
  });
654
665
 
655
666
  afterEach(() => {
656
667
  delete process.env.MANAGED_GITHUB_TOKEN;
657
668
  delete process.env.MANAGED_GITHUB_VAULT_ID;
669
+ delete process.env.MANAGED_MCP_VAULT_ID;
658
670
  });
659
671
 
660
672
  test("normalizeRepoUrl: passes through https URLs and expands owner/repo shorthand", () => {
@@ -1266,9 +1278,11 @@ describe("ClaudeManagedAdapter (Phase 6) — full happy-path integration", () =>
1266
1278
  const ctxUsage = emitted.find((e) => e.type === "context_usage");
1267
1279
  expect(ctxUsage?.type).toBe("context_usage");
1268
1280
  if (ctxUsage?.type === "context_usage") {
1269
- // 1M input + 200k output = 1.2M used; output = 200k.
1270
- expect(ctxUsage.contextUsedTokens).toBe(1_200_000);
1281
+ // Phase 5 / Phase 9 unified: input + cache_read + cache_write + output.
1282
+ // 1M + 50k + 25k + 200k = 1,275,000.
1283
+ expect(ctxUsage.contextUsedTokens).toBe(1_275_000);
1271
1284
  expect(ctxUsage.outputTokens).toBe(200_000);
1285
+ expect(ctxUsage.contextFormula).toBe("input-cache-output");
1272
1286
  }
1273
1287
 
1274
1288
  // The terminal `result` ProviderEvent — the contract Phase 4 hardened —
@@ -88,13 +88,22 @@ describe("runClaudeManagedSetupFlow — happy path", () => {
88
88
  const agentCallArgs = agentsCreate.mock.calls[0]?.[0] as {
89
89
  name: string;
90
90
  model: string;
91
- tools: Array<{ type: string }>;
91
+ tools: Array<{
92
+ type: string;
93
+ default_config?: { permission_policy?: { type: string } };
94
+ }>;
92
95
  skills: Array<{ type: string; skill_id: string }>;
93
96
  mcp_servers: Array<{ name: string; type: string; url: string }>;
94
97
  };
95
98
  expect(agentCallArgs.name).toBe("swarm-worker");
96
99
  expect(agentCallArgs.model).toBe("claude-sonnet-4-6");
97
100
  expect(agentCallArgs.tools[0]?.type).toBe("agent_toolset_20260401");
101
+ // Headless workers can't approve interactively — both toolsets must be
102
+ // configured with `always_allow` so the sandbox executes tool calls
103
+ // without parking them in `awaiting approval`.
104
+ for (const tool of agentCallArgs.tools) {
105
+ expect(tool.default_config?.permission_policy?.type).toBe("always_allow");
106
+ }
98
107
  expect(agentCallArgs.skills.map((s) => s.skill_id)).toEqual([
99
108
  "skill_work-on-task",
100
109
  "skill_create-pr",
@@ -191,16 +191,16 @@ describe("CodexSession event mapping", () => {
191
191
  expect(messages[0].content).toBe("Hello from codex");
192
192
  }
193
193
 
194
- // context_usage event fired with the *uncached + output* peak proxy
195
- // (input=100, cached=25, output=50 uncached=75 peak=125)
196
- // contextPercent is on a 0-100 scale (claude/pi convention).
194
+ // Phase 9: unified `input + output` formula (Codex `input_tokens` already
195
+ // includes cached input, so we don't add cache_read separately).
196
+ // input=100 + output=50 contextUsed=150.
197
197
  const contextUsage = emitted.find((e) => e.type === "context_usage");
198
198
  expect(contextUsage).toBeDefined();
199
199
  if (contextUsage && contextUsage.type === "context_usage") {
200
- expect(contextUsage.contextUsedTokens).toBe(125);
200
+ expect(contextUsage.contextUsedTokens).toBe(150);
201
201
  expect(contextUsage.contextTotalTokens).toBe(200_000);
202
- // 125 / 200_000 × 100 = 0.0625
203
- expect(contextUsage.contextPercent).toBeCloseTo((125 / 200_000) * 100, 6);
202
+ expect(contextUsage.contextPercent).toBeCloseTo((150 / 200_000) * 100, 6);
203
+ expect(contextUsage.contextFormula).toBe("input-cache-output");
204
204
  }
205
205
 
206
206
  // result event is final and non-error, with cost computed from token counts
@@ -225,14 +225,15 @@ describe("CodexSession event mapping", () => {
225
225
  expect(result.sessionId).toBe("thread-abc");
226
226
  });
227
227
 
228
- test("chatty turn: peakContextPercent uses uncached + output, not raw input_tokens", async () => {
229
- // Reproduces the verify-plan finding: a chatty turn where the SDK reports
230
- // input_tokens far in excess of the model's context window because the
231
- // total represents the SUM of every prompt across all model invocations
232
- // in the turn (with cache reuses billed at every roundtrip). Without the
233
- // peak-proxy fix this would clamp `contextPercent` to 1.0 even though no
234
- // single model call hit the limit. Use realistic numbers from the actual
235
- // E2E lead transcript captured during verification.
228
+ test("Phase 9: chatty turn clamps contextPercent to 100% under the unified formula", async () => {
229
+ // Phase 9 deliberately swapped Codex's per-adapter peak-proxy formula
230
+ // (`(input - cached) + output`) for the unified `input + output` formula
231
+ // shared with every other provider. The trade-off: a chatty Codex turn
232
+ // where `input_tokens` is the SUM across every model call in the turn
233
+ // — over-reports compared to the peak-proxy variant. The clamp at 100%
234
+ // keeps the gauge sensible; downstream consumers reading the new
235
+ // `contextFormula='input-cache-output'` tag know it's apples-to-apples
236
+ // across providers. Numbers below are from the verify-plan transcript.
236
237
  const agentMsg: AgentMessageItem = {
237
238
  id: "msg-1",
238
239
  type: "agent_message",
@@ -262,12 +263,12 @@ describe("CodexSession event mapping", () => {
262
263
  const contextUsage = emitted.find((e) => e.type === "context_usage");
263
264
  expect(contextUsage).toBeDefined();
264
265
  if (contextUsage && contextUsage.type === "context_usage") {
265
- // peak proxy = (357142 - 278912) + 2156 = 78230 + 2156 = 80386
266
- expect(contextUsage.contextUsedTokens).toBe(80386);
266
+ // Phase 9 unified: input + output = 357142 + 2156 = 359298 (above 200k).
267
+ expect(contextUsage.contextUsedTokens).toBe(359298);
267
268
  expect(contextUsage.contextTotalTokens).toBe(200_000);
268
- // 80386 / 200000 × 100 = 40.193 — on the 0-100 scale, NOT clamped to 100
269
- expect(contextUsage.contextPercent).toBeCloseTo(40.193, 2);
270
- expect(contextUsage.contextPercent).toBeLessThan(100);
269
+ // Above 100% raw clamped to exactly 100.
270
+ expect(contextUsage.contextPercent).toBe(100);
271
+ expect(contextUsage.contextFormula).toBe("input-cache-output");
271
272
  }
272
273
 
273
274
  // Cost still uses the full input_tokens — billing semantics are
@@ -70,7 +70,7 @@ describe("resolveCodexLoginConfig", () => {
70
70
  expect(promptSecret).toHaveBeenCalledWith(
71
71
  "Swarm API key",
72
72
  "env-secret",
73
- "Press Enter to use API_KEY from the environment",
73
+ "Press Enter to use AGENT_SWARM_API_KEY/API_KEY from the environment",
74
74
  );
75
75
  });
76
76
 
@@ -82,7 +82,7 @@ describe("Context Snapshots", () => {
82
82
 
83
83
  // The summary should preserve the last known context usage, not null/0
84
84
  const summary = getContextSummaryByTaskId(taskId);
85
- expect(summary.totalContextTokensUsed).toBe(80000);
85
+ expect(summary.peakContextTokens).toBe(80000);
86
86
  expect(summary.contextWindowSize).toBe(200000);
87
87
  expect(summary.peakContextPercent).toBe(40);
88
88
  });
@@ -113,7 +113,7 @@ describe("Context Snapshots", () => {
113
113
  });
114
114
 
115
115
  const summary = getContextSummaryByTaskId(task2.id);
116
- expect(summary.totalContextTokensUsed).toBe(60000);
116
+ expect(summary.peakContextTokens).toBe(60000);
117
117
  expect(summary.contextWindowSize).toBe(200000);
118
118
  });
119
119
 
@@ -1,8 +1,15 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
2
+ import {
3
+ CONTEXT_FORMULA,
4
+ clampContextPercent,
5
+ computeContextUsed,
6
+ computeContextUsedUnified,
7
+ getContextWindowSize,
8
+ } from "../utils/context-window";
3
9
 
4
10
  describe("getContextWindowSize", () => {
5
11
  test("returns 1M for opus models", () => {
12
+ expect(getContextWindowSize("claude-opus-4-7")).toBe(1_000_000);
6
13
  expect(getContextWindowSize("claude-opus-4-6")).toBe(1_000_000);
7
14
  expect(getContextWindowSize("opus")).toBe(1_000_000);
8
15
  });
@@ -26,6 +33,20 @@ describe("getContextWindowSize", () => {
26
33
  test("returns default entry value", () => {
27
34
  expect(getContextWindowSize("default")).toBe(200_000);
28
35
  });
36
+
37
+ test("Phase 4: dated full ids resolve via date-suffix stripping", () => {
38
+ // The regression this fixes: pre-Phase 4 these all fell to the 200k
39
+ // default, wildly understating opus/sonnet 4.x context.
40
+ expect(getContextWindowSize("claude-sonnet-4-6-20251004")).toBe(1_000_000);
41
+ expect(getContextWindowSize("claude-opus-4-7-20251201")).toBe(1_000_000);
42
+ expect(getContextWindowSize("claude-haiku-4-5-20251001")).toBe(200_000);
43
+ });
44
+
45
+ test("Phase 4: legacy 3.x family ids resolve", () => {
46
+ expect(getContextWindowSize("claude-3-5-sonnet")).toBe(200_000);
47
+ expect(getContextWindowSize("claude-3-5-sonnet-20241022")).toBe(200_000);
48
+ expect(getContextWindowSize("claude-3-opus")).toBe(200_000);
49
+ });
29
50
  });
30
51
 
31
52
  describe("computeContextUsed", () => {
@@ -64,3 +85,46 @@ describe("computeContextUsed", () => {
64
85
  ).toBe(5000);
65
86
  });
66
87
  });
88
+
89
+ describe("computeContextUsedUnified (Phase 9 unified formula)", () => {
90
+ test("sums input + cache_read + cache_create + output", () => {
91
+ expect(
92
+ computeContextUsedUnified({
93
+ inputTokens: 1000,
94
+ cacheReadTokens: 200,
95
+ cacheCreateTokens: 300,
96
+ outputTokens: 500,
97
+ }),
98
+ ).toBe(2000);
99
+ });
100
+
101
+ test("treats missing/null fields as zero", () => {
102
+ expect(computeContextUsedUnified({})).toBe(0);
103
+ expect(computeContextUsedUnified({ inputTokens: 100, outputTokens: null })).toBe(100);
104
+ });
105
+ });
106
+
107
+ describe("clampContextPercent (Phase 9)", () => {
108
+ test("returns the clamped percent for valid inputs", () => {
109
+ expect(clampContextPercent(50_000, 200_000)).toBe(25);
110
+ expect(clampContextPercent(0, 200_000)).toBe(0);
111
+ });
112
+
113
+ test("clamps to [0, 100]", () => {
114
+ expect(clampContextPercent(500_000, 200_000)).toBe(100);
115
+ expect(clampContextPercent(-10, 200_000)).toBe(0);
116
+ });
117
+
118
+ test("returns null for missing/zero/negative total (no divide-by-zero NaN)", () => {
119
+ expect(clampContextPercent(100, 0)).toBeNull();
120
+ expect(clampContextPercent(100, null)).toBeNull();
121
+ expect(clampContextPercent(100, undefined)).toBeNull();
122
+ expect(clampContextPercent(100, -1)).toBeNull();
123
+ });
124
+ });
125
+
126
+ describe("CONTEXT_FORMULA constant", () => {
127
+ test("is 'input-cache-output' so adapters stamp the same value on snapshots", () => {
128
+ expect(CONTEXT_FORMULA).toBe("input-cache-output");
129
+ });
130
+ });
@@ -598,6 +598,8 @@ describe("CostData mapping", () => {
598
598
  expect(resultEvent.cost.model).toBe("devin");
599
599
  expect(resultEvent.cost.inputTokens).toBe(0);
600
600
  expect(resultEvent.cost.outputTokens).toBe(0);
601
+ // Phase 3 — provider tag is required so the API recompute path engages.
602
+ expect(resultEvent.cost.provider).toBe("devin");
601
603
  }
602
604
  });
603
605
 
@@ -0,0 +1,161 @@
1
+ // Phase 10: HTTP context-route ingestion semantics.
2
+ //
3
+ // Asserts:
4
+ // * `agent_tasks.peakContextTokens` is monotonic-max (a dip on a later
5
+ // snapshot doesn't reduce the stored value).
6
+ // * `agent_tasks.contextWindowSize` is set on the FIRST snapshot that
7
+ // carries one, not gated on `eventType='completion'`.
8
+ // * `cumulativeInputTokens` round-trips through the route into the
9
+ // persisted snapshot row.
10
+ // * `contextFormula` round-trips into the snapshot.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { unlink } from "node:fs/promises";
14
+ import {
15
+ createServer as createHttpServer,
16
+ type IncomingMessage,
17
+ type Server,
18
+ type ServerResponse,
19
+ } from "node:http";
20
+ import {
21
+ closeDb,
22
+ createAgent,
23
+ createTaskExtended,
24
+ getContextSnapshotsByTaskId,
25
+ getContextSummaryByTaskId,
26
+ initDb,
27
+ } from "../../be/db";
28
+ import { handleContext } from "../../http/context";
29
+ import { handleCore } from "../../http/core";
30
+ import { getPathSegments, parseQueryParams } from "../../http/utils";
31
+
32
+ const TEST_DB_PATH = "./test-context-routes.sqlite";
33
+ const API_KEY = "test-context-routes";
34
+
35
+ async function removeDbFiles(path: string): Promise<void> {
36
+ for (const suffix of ["", "-wal", "-shm"]) {
37
+ try {
38
+ await unlink(path + suffix);
39
+ } catch (error) {
40
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
41
+ }
42
+ }
43
+ }
44
+
45
+ async function listen(server: Server): Promise<number> {
46
+ await new Promise<void>((resolve) => server.listen(0, resolve));
47
+ const addr = server.address();
48
+ if (!addr || typeof addr === "string") throw new Error("no port");
49
+ return addr.port;
50
+ }
51
+
52
+ function createTestServer(apiKey: string): Server {
53
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
54
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
55
+ const handled = await handleCore(req, res, myAgentId, apiKey);
56
+ if (handled) return;
57
+ const pathSegments = getPathSegments(req.url || "");
58
+ const queryParams = parseQueryParams(req.url || "");
59
+ const ok = await handleContext(req, res, pathSegments, queryParams, myAgentId);
60
+ if (!ok) {
61
+ res.writeHead(404);
62
+ res.end("Not Found");
63
+ }
64
+ });
65
+ }
66
+
67
+ let server: Server;
68
+ let port: number;
69
+ let testAgent: { id: string };
70
+ let testTask: { id: string };
71
+
72
+ beforeAll(async () => {
73
+ await removeDbFiles(TEST_DB_PATH);
74
+ initDb(TEST_DB_PATH);
75
+ testAgent = createAgent({ name: "context-route-test", isLead: false, status: "idle" });
76
+ testTask = createTaskExtended("phase-10 ingestion", { agentId: testAgent.id, source: "mcp" });
77
+ server = createTestServer(API_KEY);
78
+ port = await listen(server);
79
+ });
80
+
81
+ afterAll(async () => {
82
+ await new Promise<void>((resolve) => server.close(() => resolve()));
83
+ closeDb();
84
+ await removeDbFiles(TEST_DB_PATH);
85
+ });
86
+
87
+ function postSnapshot(body: Record<string, unknown>): Promise<Response> {
88
+ return fetch(`http://localhost:${port}/api/tasks/${testTask.id}/context`, {
89
+ method: "POST",
90
+ headers: {
91
+ Authorization: `Bearer ${API_KEY}`,
92
+ "X-Agent-ID": testAgent.id,
93
+ "Content-Type": "application/json",
94
+ },
95
+ body: JSON.stringify(body),
96
+ });
97
+ }
98
+
99
+ describe("Phase 10 — POST /api/tasks/:id/context", () => {
100
+ test("peakContextTokens is a monotonic max across snapshots", async () => {
101
+ const r1 = await postSnapshot({
102
+ eventType: "progress",
103
+ sessionId: "sess-1",
104
+ contextUsedTokens: 50_000,
105
+ contextTotalTokens: 200_000,
106
+ contextPercent: 25,
107
+ });
108
+ expect(r1.status).toBe(200);
109
+
110
+ const r2 = await postSnapshot({
111
+ eventType: "progress",
112
+ sessionId: "sess-1",
113
+ contextUsedTokens: 120_000,
114
+ contextTotalTokens: 200_000,
115
+ contextPercent: 60,
116
+ });
117
+ expect(r2.status).toBe(200);
118
+
119
+ // Dip — the unified formula occasionally undercounts on the next turn
120
+ // (e.g. when the SDK reuses cache more aggressively). The aggregate
121
+ // column must NOT regress to the dipped value.
122
+ const r3 = await postSnapshot({
123
+ eventType: "progress",
124
+ sessionId: "sess-1",
125
+ contextUsedTokens: 80_000,
126
+ contextTotalTokens: 200_000,
127
+ contextPercent: 40,
128
+ });
129
+ expect(r3.status).toBe(200);
130
+
131
+ const summary = getContextSummaryByTaskId(testTask.id);
132
+ expect(summary.peakContextTokens).toBe(120_000);
133
+ });
134
+
135
+ test("contextWindowSize is set on the first snapshot, not on completion", () => {
136
+ // The first POST in the previous test already set this; assert it stuck
137
+ // and a later POST with a different total doesn't overwrite it.
138
+ const summary = getContextSummaryByTaskId(testTask.id);
139
+ expect(summary.contextWindowSize).toBe(200_000);
140
+ });
141
+
142
+ test("cumulativeInputTokens + contextFormula round-trip into the row", async () => {
143
+ const res = await postSnapshot({
144
+ eventType: "progress",
145
+ sessionId: "sess-2",
146
+ contextUsedTokens: 30_000,
147
+ contextTotalTokens: 200_000,
148
+ contextPercent: 15,
149
+ cumulativeInputTokens: 1234,
150
+ cumulativeOutputTokens: 567,
151
+ contextFormula: "input-cache-output",
152
+ });
153
+ expect(res.status).toBe(200);
154
+
155
+ const snapshots = getContextSnapshotsByTaskId(testTask.id);
156
+ const last = snapshots[snapshots.length - 1];
157
+ expect(last.cumulativeInputTokens).toBe(1234);
158
+ expect(last.cumulativeOutputTokens).toBe(567);
159
+ expect(last.contextFormula).toBe("input-cache-output");
160
+ });
161
+ });
@@ -3,6 +3,7 @@ import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
4
  import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
5
5
  import { initLinearOutboundSync, teardownLinearOutboundSync } from "../linear/outbound";
6
+ import { taskSessionMap } from "../linear/sync";
6
7
  import { workflowEventBus } from "../workflows/event-bus";
7
8
 
8
9
  const TEST_DB_PATH = "./test-linear-outbound-sync.sqlite";
@@ -17,6 +18,19 @@ mock.module("../linear/client", () => ({
17
18
  resetLinearClient: () => {},
18
19
  }));
19
20
 
21
+ // Mock the AgentSession helpers in linear/sync so we can assert which activity type
22
+ // the outbound handlers post (`action` vs `thought` vs `response`/`error`).
23
+ const mockPostAgentSessionThought = mock(() => Promise.resolve());
24
+ const mockPostAgentSessionAction = mock(() => Promise.resolve());
25
+ const mockEndAgentSession = mock(() => Promise.resolve());
26
+
27
+ mock.module("../linear/sync", () => ({
28
+ postAgentSessionThought: mockPostAgentSessionThought,
29
+ postAgentSessionAction: mockPostAgentSessionAction,
30
+ endAgentSession: mockEndAgentSession,
31
+ taskSessionMap,
32
+ }));
33
+
20
34
  beforeAll(() => {
21
35
  initDb(TEST_DB_PATH);
22
36
  });
@@ -31,11 +45,16 @@ afterAll(async () => {
31
45
  describe("Linear Outbound Sync", () => {
32
46
  beforeEach(() => {
33
47
  mockCreateComment.mockClear();
48
+ mockPostAgentSessionThought.mockClear();
49
+ mockPostAgentSessionAction.mockClear();
50
+ mockEndAgentSession.mockClear();
51
+ taskSessionMap.clear();
34
52
  initLinearOutboundSync();
35
53
  });
36
54
 
37
55
  afterEach(() => {
38
56
  teardownLinearOutboundSync();
57
+ taskSessionMap.clear();
39
58
  });
40
59
 
41
60
  test("task.completed posts comment to Linear when mapping exists", async () => {
@@ -177,6 +196,96 @@ describe("Linear Outbound Sync", () => {
177
196
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
178
197
  });
179
198
 
199
+ test("task.progress posts an action activity with both action AND parameter when sessionId is mapped", async () => {
200
+ const taskId = "outbound-task-progress";
201
+ taskSessionMap.set(taskId, "linear-session-123");
202
+
203
+ workflowEventBus.emit("task.progress", {
204
+ taskId,
205
+ progress: "📋 Reviewing task details",
206
+ });
207
+
208
+ await new Promise((resolve) => setTimeout(resolve, 10));
209
+
210
+ // Posts as `action` so the update renders as a structured card in Linear's AgentSession
211
+ // panel. Linear's spec requires BOTH `action` AND `parameter` for action-type activities;
212
+ // the original bug was calling postAgentSessionAction with only a single string (parameter
213
+ // undefined), which Linear silently rejected.
214
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
215
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
216
+
217
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
218
+ expect(args[0]).toBe("linear-session-123");
219
+ // Both action label and parameter must be present and non-empty
220
+ expect(typeof args[1]).toBe("string");
221
+ expect((args[1] as string).length).toBeGreaterThan(0);
222
+ expect(typeof args[2]).toBe("string");
223
+ expect((args[2] as string).length).toBeGreaterThan(0);
224
+ // Parameter carries the actual progress text
225
+ expect(args[2] as string).toBe("📋 Reviewing task details");
226
+ });
227
+
228
+ test("task.progress slices long progress strings into the parameter (cap at 2000)", async () => {
229
+ const taskId = "outbound-task-progress-long";
230
+ taskSessionMap.set(taskId, "linear-session-long");
231
+
232
+ const longProgress = "x".repeat(5000);
233
+ workflowEventBus.emit("task.progress", { taskId, progress: longProgress });
234
+
235
+ await new Promise((resolve) => setTimeout(resolve, 10));
236
+
237
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
238
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
239
+ expect((args[2] as string).length).toBe(2000);
240
+ });
241
+
242
+ test("task.progress is a no-op when no sessionId is mapped for the task", async () => {
243
+ workflowEventBus.emit("task.progress", {
244
+ taskId: "outbound-task-progress-no-session",
245
+ progress: "should be dropped",
246
+ });
247
+
248
+ await new Promise((resolve) => setTimeout(resolve, 10));
249
+
250
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
251
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
252
+ });
253
+
254
+ test("task.progress is a no-op when progress string is missing", async () => {
255
+ taskSessionMap.set("outbound-task-progress-empty", "linear-session-empty");
256
+
257
+ workflowEventBus.emit("task.progress", {
258
+ taskId: "outbound-task-progress-empty",
259
+ });
260
+
261
+ await new Promise((resolve) => setTimeout(resolve, 10));
262
+
263
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
264
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("task.created for Linear-sourced tasks still posts an action activity (with parameter)", async () => {
268
+ const taskId = "outbound-task-created-linear";
269
+ taskSessionMap.set(taskId, "linear-session-created");
270
+
271
+ workflowEventBus.emit("task.created", {
272
+ taskId,
273
+ source: "linear",
274
+ });
275
+
276
+ await new Promise((resolve) => setTimeout(resolve, 10));
277
+
278
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
279
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
280
+
281
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
282
+ expect(args[0]).toBe("linear-session-created");
283
+ expect(args[1]).toBe("Processing");
284
+ // parameter (3rd positional arg) must be present for `action` activities to be valid
285
+ expect(typeof args[2]).toBe("string");
286
+ expect(args[2] as string).toContain(taskId);
287
+ });
288
+
180
289
  test("teardown removes event listeners", async () => {
181
290
  teardownLinearOutboundSync();
182
291
 
@@ -0,0 +1,69 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb } from "../be/db";
4
+ import { createServer } from "../server";
5
+
6
+ const TEST_DB_PATH = "./test-mcp-tools.sqlite";
7
+
8
+ type RegisteredTool = {
9
+ title?: string;
10
+ description?: string;
11
+ inputSchema?: unknown;
12
+ outputSchema?: unknown;
13
+ annotations?: Record<string, unknown>;
14
+ };
15
+
16
+ async function removeDbFiles(path: string): Promise<void> {
17
+ for (const suffix of ["", "-wal", "-shm"]) {
18
+ try {
19
+ await unlink(path + suffix);
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ }
23
+ }
24
+ }
25
+
26
+ describe("script MCP tools", () => {
27
+ let tools: Record<string, RegisteredTool>;
28
+ let savedDatabasePath: string | undefined;
29
+
30
+ beforeAll(async () => {
31
+ savedDatabasePath = process.env.DATABASE_PATH;
32
+ process.env.DATABASE_PATH = TEST_DB_PATH;
33
+ await removeDbFiles(TEST_DB_PATH);
34
+ const server = createServer();
35
+ tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
36
+ ._registeredTools;
37
+ });
38
+
39
+ afterAll(async () => {
40
+ closeDb();
41
+ if (savedDatabasePath === undefined) delete process.env.DATABASE_PATH;
42
+ else process.env.DATABASE_PATH = savedDatabasePath;
43
+ await removeDbFiles(TEST_DB_PATH);
44
+ });
45
+
46
+ test("registers all script tools with schemas and documented descriptions", () => {
47
+ const expected = {
48
+ "script-search":
49
+ "Semantic search over swarm-shared TypeScript scripts (catalog persisted in the agent-swarm DB; callable from agents and workflows). For ephemeral throwaway TS on your local machine, use code-mode instead.",
50
+ "script-run":
51
+ "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.",
52
+ "script-upsert":
53
+ "Persist a TypeScript script to the swarm catalog under your agent scope (or global if you're a lead). Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.",
54
+ "script-delete":
55
+ "Remove a swarm-shared script from the catalog. Versions table preserves history.",
56
+ "script-query-types":
57
+ "Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
58
+ };
59
+
60
+ for (const [name, description] of Object.entries(expected)) {
61
+ expect(tools[name]).toBeDefined();
62
+ expect(tools[name].title).toBeTruthy();
63
+ expect(tools[name].description).toBe(description);
64
+ expect(tools[name].inputSchema).toBeTruthy();
65
+ expect(tools[name].outputSchema).toBeTruthy();
66
+ expect(tools[name].annotations).toBeTruthy();
67
+ }
68
+ });
69
+ });