@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.
- package/openapi.json +496 -32
- package/package.json +14 -6
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +102 -31
- package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
- package/src/be/migrations/064_scripts.sql +39 -0
- package/src/be/migrations/065_script_embeddings.sql +7 -0
- package/src/be/pricing-normalize.ts +81 -0
- package/src/be/scripts/db.ts +391 -0
- package/src/be/scripts/embeddings.ts +231 -0
- package/src/be/scripts/maintenance.ts +9 -0
- package/src/be/scripts/typecheck.ts +193 -0
- package/src/be/seed-pricing.ts +293 -0
- package/src/cli.tsx +22 -5
- package/src/commands/artifact.ts +3 -2
- package/src/commands/claude-managed-setup.ts +21 -4
- package/src/commands/codex-login.ts +5 -3
- package/src/commands/onboard.tsx +2 -1
- package/src/commands/runner.ts +663 -246
- package/src/commands/setup.tsx +5 -3
- package/src/hooks/hook.ts +4 -3
- package/src/http/context.ts +6 -2
- package/src/http/index.ts +126 -68
- package/src/http/memory.ts +28 -0
- package/src/http/openapi.ts +1 -0
- package/src/http/page-proxy.ts +2 -1
- package/src/http/route-def.ts +1 -0
- package/src/http/schedules.ts +37 -0
- package/src/http/scripts.ts +381 -0
- package/src/http/session-data.ts +74 -23
- package/src/linear/outbound.ts +9 -2
- package/src/otel-impl.ts +200 -0
- package/src/otel.ts +132 -0
- package/src/providers/claude-adapter.ts +52 -6
- package/src/providers/claude-managed-adapter.ts +43 -17
- package/src/providers/claude-managed-pricing.ts +34 -0
- package/src/providers/codex-adapter.ts +38 -27
- package/src/providers/codex-models.ts +22 -3
- package/src/providers/devin-adapter.ts +11 -0
- package/src/providers/opencode-adapter.ts +31 -7
- package/src/providers/pi-mono-adapter.ts +39 -7
- package/src/providers/pricing-sources.md +52 -0
- package/src/providers/swarm-events-shared.ts +8 -4
- package/src/providers/types.ts +33 -10
- package/src/scripts-runtime/ctx.ts +23 -0
- package/src/scripts-runtime/eval-harness.ts +39 -0
- package/src/scripts-runtime/executors/native.ts +229 -0
- package/src/scripts-runtime/executors/registry.ts +16 -0
- package/src/scripts-runtime/executors/types.ts +63 -0
- package/src/scripts-runtime/extract-signature.ts +81 -0
- package/src/scripts-runtime/import-allowlist.ts +109 -0
- package/src/scripts-runtime/loader.ts +96 -0
- package/src/scripts-runtime/redacted.ts +48 -0
- package/src/scripts-runtime/sdk-allowlist.ts +29 -0
- package/src/scripts-runtime/stdlib/fetch.ts +46 -0
- package/src/scripts-runtime/stdlib/glob.ts +8 -0
- package/src/scripts-runtime/stdlib/grep.ts +34 -0
- package/src/scripts-runtime/stdlib/index.ts +16 -0
- package/src/scripts-runtime/stdlib/table.ts +17 -0
- package/src/scripts-runtime/swarm-config.ts +35 -0
- package/src/scripts-runtime/swarm-sdk.ts +197 -0
- package/src/scripts-runtime/types/stdlib.d.ts +104 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
- package/src/server.ts +18 -0
- package/src/tests/api-key.test.ts +33 -0
- package/src/tests/claude-managed-adapter.test.ts +17 -3
- package/src/tests/claude-managed-setup.test.ts +10 -1
- package/src/tests/codex-adapter.test.ts +20 -19
- package/src/tests/codex-login.test.ts +1 -1
- package/src/tests/context-snapshot.test.ts +2 -2
- package/src/tests/context-window.test.ts +65 -1
- package/src/tests/devin-adapter.test.ts +2 -0
- package/src/tests/http/context-routes.test.ts +161 -0
- package/src/tests/linear-outbound-sync.test.ts +109 -0
- package/src/tests/mcp-tools.test.ts +69 -0
- package/src/tests/migration-063-schema-relax.test.ts +109 -0
- package/src/tests/opencode-adapter.test.ts +146 -1
- package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
- package/src/tests/pages-view-count.test.ts +30 -5
- package/src/tests/providers/codex-cost.test.ts +18 -0
- package/src/tests/providers/opencode-cost.test.ts +74 -0
- package/src/tests/providers/pi-cost.test.ts +128 -0
- package/src/tests/redacted.test.ts +29 -0
- package/src/tests/runner-tool-spans.test.ts +268 -0
- package/src/tests/script-executor-conformance.test.ts +142 -0
- package/src/tests/script-executor-registry.test.ts +17 -0
- package/src/tests/scripts-db.test.ts +329 -0
- package/src/tests/scripts-embeddings.test.ts +291 -0
- package/src/tests/scripts-extract-signature.test.ts +47 -0
- package/src/tests/scripts-http.test.ts +350 -0
- package/src/tests/scripts-import-allowlist.test.ts +55 -0
- package/src/tests/scripts-mcp-e2e.test.ts +269 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
- package/src/tests/scripts-runtime.test.ts +289 -0
- package/src/tests/sdk-allowlist.test.ts +59 -0
- package/src/tests/secret-scrubber.test.ts +54 -1
- package/src/tests/session-costs-codex-recompute.test.ts +35 -22
- package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
- package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
- package/src/tests/store-progress-cost.test.ts +6 -1
- package/src/tests/swarm-config.test.ts +38 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/tool-call-progress.test.ts +30 -0
- package/src/tests/workflow-e2e.test.ts +218 -0
- package/src/tests/workflow-executors.test.ts +32 -2
- package/src/tests/workflow-input-redaction.test.ts +232 -0
- package/src/tests/workflow-swarm-script.test.ts +273 -0
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/script-common.ts +88 -0
- package/src/tools/script-delete.ts +35 -0
- package/src/tools/script-query-types.ts +37 -0
- package/src/tools/script-run.ts +43 -0
- package/src/tools/script-search.ts +32 -0
- package/src/tools/script-upsert.ts +43 -0
- package/src/tools/store-progress.ts +16 -60
- package/src/tools/tool-config.ts +7 -0
- package/src/tools/utils.ts +65 -12
- package/src/types.ts +122 -10
- package/src/utils/api-key.ts +28 -0
- package/src/utils/context-window.ts +104 -4
- package/src/utils/page-session.ts +8 -6
- package/src/utils/secret-scrubber.ts +29 -1
- package/src/workflows/engine.ts +12 -4
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +12 -1
- package/src/workflows/executors/swarm-script.ts +170 -0
- package/src/workflows/input.ts +65 -0
- package/src/workflows/recovery.ts +31 -3
- 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(
|
|
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
|
-
//
|
|
1270
|
-
|
|
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<{
|
|
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
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
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(
|
|
200
|
+
expect(contextUsage.contextUsedTokens).toBe(150);
|
|
201
201
|
expect(contextUsage.contextTotalTokens).toBe(200_000);
|
|
202
|
-
|
|
203
|
-
expect(contextUsage.
|
|
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
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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
|
-
//
|
|
266
|
-
expect(contextUsage.contextUsedTokens).toBe(
|
|
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
|
-
//
|
|
269
|
-
expect(contextUsage.contextPercent).
|
|
270
|
-
expect(contextUsage.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
+
});
|