@desplega.ai/agent-swarm 1.79.4 → 1.80.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 (49) hide show
  1. package/openapi.json +98 -19
  2. package/package.json +12 -6
  3. package/src/be/db.ts +101 -30
  4. package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
  5. package/src/be/pricing-normalize.ts +81 -0
  6. package/src/be/seed-pricing.ts +293 -0
  7. package/src/commands/claude-managed-setup.ts +19 -3
  8. package/src/commands/runner.ts +592 -237
  9. package/src/http/context.ts +6 -2
  10. package/src/http/index.ts +115 -68
  11. package/src/http/session-data.ts +74 -23
  12. package/src/otel-impl.ts +200 -0
  13. package/src/otel.ts +127 -0
  14. package/src/providers/claude-adapter.ts +30 -5
  15. package/src/providers/claude-managed-adapter.ts +43 -17
  16. package/src/providers/claude-managed-pricing.ts +34 -0
  17. package/src/providers/codex-adapter.ts +38 -27
  18. package/src/providers/codex-models.ts +22 -3
  19. package/src/providers/devin-adapter.ts +11 -0
  20. package/src/providers/opencode-adapter.ts +31 -7
  21. package/src/providers/pi-mono-adapter.ts +39 -7
  22. package/src/providers/pricing-sources.md +52 -0
  23. package/src/providers/swarm-events-shared.ts +8 -4
  24. package/src/providers/types.ts +33 -10
  25. package/src/server.ts +6 -0
  26. package/src/tests/claude-managed-adapter.test.ts +17 -3
  27. package/src/tests/claude-managed-setup.test.ts +10 -1
  28. package/src/tests/codex-adapter.test.ts +20 -19
  29. package/src/tests/context-snapshot.test.ts +2 -2
  30. package/src/tests/context-window.test.ts +65 -1
  31. package/src/tests/devin-adapter.test.ts +2 -0
  32. package/src/tests/http/context-routes.test.ts +161 -0
  33. package/src/tests/migration-063-schema-relax.test.ts +109 -0
  34. package/src/tests/opencode-adapter.test.ts +146 -1
  35. package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
  36. package/src/tests/pages-view-count.test.ts +30 -5
  37. package/src/tests/providers/codex-cost.test.ts +18 -0
  38. package/src/tests/providers/opencode-cost.test.ts +74 -0
  39. package/src/tests/providers/pi-cost.test.ts +128 -0
  40. package/src/tests/secret-scrubber.test.ts +19 -0
  41. package/src/tests/session-costs-codex-recompute.test.ts +35 -22
  42. package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
  43. package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
  44. package/src/tests/store-progress-cost.test.ts +6 -1
  45. package/src/tools/store-progress.ts +16 -60
  46. package/src/tools/utils.ts +65 -12
  47. package/src/types.ts +62 -9
  48. package/src/utils/context-window.ts +104 -4
  49. package/src/utils/secret-scrubber.ts +7 -0
@@ -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
+ });
@@ -0,0 +1,109 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDb, initDb } from "../be/db";
4
+
5
+ const TEST_DB_PATH = "./test-migration-063.sqlite";
6
+
7
+ describe("Migration 063 — cost & context schema relax", () => {
8
+ beforeAll(async () => {
9
+ for (const suffix of ["", "-wal", "-shm"]) {
10
+ try {
11
+ await unlink(TEST_DB_PATH + suffix);
12
+ } catch {
13
+ // doesn't exist
14
+ }
15
+ }
16
+ initDb(TEST_DB_PATH);
17
+ });
18
+
19
+ afterAll(async () => {
20
+ closeDb();
21
+ for (const suffix of ["", "-wal", "-shm"]) {
22
+ try {
23
+ await unlink(TEST_DB_PATH + suffix);
24
+ } catch {
25
+ // ignore
26
+ }
27
+ }
28
+ });
29
+
30
+ test("pricing CHECKs are dropped — accepts every provider in the new Zod enum", () => {
31
+ const stmt = getDb().prepare(
32
+ `INSERT INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
33
+ VALUES (?, ?, ?, 0, 1.0, 0, 0)`,
34
+ );
35
+
36
+ for (const provider of [
37
+ "claude",
38
+ "claude-managed",
39
+ "codex",
40
+ "pi",
41
+ "opencode",
42
+ "devin",
43
+ "gemini",
44
+ ]) {
45
+ expect(() => stmt.run(provider, "test-model", "input")).not.toThrow();
46
+ }
47
+
48
+ for (const tokenClass of [
49
+ "input",
50
+ "cached_input",
51
+ "output",
52
+ "cache_write",
53
+ "runtime_hour",
54
+ "acu",
55
+ ]) {
56
+ expect(() => stmt.run("claude-managed", "mm", tokenClass)).not.toThrow();
57
+ }
58
+ });
59
+
60
+ test("agent_tasks.totalContextTokensUsed renamed to peakContextTokens", () => {
61
+ const cols = getDb()
62
+ .prepare<{ name: string }, []>("PRAGMA table_info(agent_tasks)")
63
+ .all() as Array<{ name: string }>;
64
+ const names = new Set(cols.map((c) => c.name));
65
+ expect(names.has("peakContextTokens")).toBe(true);
66
+ expect(names.has("totalContextTokensUsed")).toBe(false);
67
+ });
68
+
69
+ test("task_context_snapshots has contextFormula column", () => {
70
+ const cols = getDb()
71
+ .prepare<{ name: string }, []>("PRAGMA table_info(task_context_snapshots)")
72
+ .all() as Array<{ name: string }>;
73
+ expect(cols.some((c) => c.name === "contextFormula")).toBe(true);
74
+ });
75
+
76
+ test("session_costs has reasoningOutputTokens + thinkingTokens", () => {
77
+ const cols = getDb()
78
+ .prepare<{ name: string; dflt_value: string | null }, []>("PRAGMA table_info(session_costs)")
79
+ .all() as Array<{ name: string; dflt_value: string | null }>;
80
+ const byName = new Map(cols.map((c) => [c.name, c]));
81
+ expect(byName.has("reasoningOutputTokens")).toBe(true);
82
+ expect(byName.has("thinkingTokens")).toBe(true);
83
+ expect(byName.get("reasoningOutputTokens")?.dflt_value).toBe("0");
84
+ expect(byName.get("thinkingTokens")?.dflt_value).toBe("0");
85
+ });
86
+
87
+ test("session_costs.costSource CHECK is dropped — accepts 'unpriced'", () => {
88
+ // Insert a row using the relaxed costSource. We use a raw INSERT (no FKs)
89
+ // so we don't have to seed agents/tasks. Disable FK enforcement for the
90
+ // test since we don't care about referential integrity here.
91
+ getDb().exec("PRAGMA foreign_keys = OFF");
92
+ const stmt = getDb().prepare(
93
+ `INSERT INTO session_costs
94
+ (id, sessionId, taskId, agentId, totalCostUsd, durationMs, numTurns, model, costSource, createdAt)
95
+ VALUES (?, ?, NULL, ?, 0, 0, NULL, 'm', ?, '2026-05-15T00:00:00.000Z')`,
96
+ );
97
+ expect(() => stmt.run(crypto.randomUUID(), "s", "a", "unpriced")).not.toThrow();
98
+ getDb().exec("PRAGMA foreign_keys = ON");
99
+ });
100
+
101
+ test("session_costs.numTurns and cacheWriteTokens are nullable", () => {
102
+ const cols = getDb()
103
+ .prepare<{ name: string; notnull: number }, []>("PRAGMA table_info(session_costs)")
104
+ .all() as Array<{ name: string; notnull: number }>;
105
+ const byName = new Map(cols.map((c) => [c.name, c]));
106
+ expect(byName.get("numTurns")?.notnull).toBe(0);
107
+ expect(byName.get("cacheWriteTokens")?.notnull).toBe(0);
108
+ });
109
+ });
@@ -225,7 +225,9 @@ describe("OpencodeSession — cost aggregation", () => {
225
225
  reasoning: 0,
226
226
  cache: { read: i * 2, write: i },
227
227
  },
228
- time: { created: Date.now() },
228
+ // Phase 9 fix: accumulator gates on `time.completed` so simulated steps
229
+ // must look like finalized opencode messages.
230
+ time: { created: Date.now(), completed: Date.now() + 1 },
229
231
  parentID: "",
230
232
  modelID: "claude-opus",
231
233
  providerID: "anthropic",
@@ -317,6 +319,149 @@ describe("OpencodeSession — raw_log persistence", () => {
317
319
  });
318
320
  });
319
321
 
322
+ // ── Phase 9: context_usage emission ───────────────────────────────────────────
323
+
324
+ describe("OpencodeSession — context_usage emission (phase 9 fix)", () => {
325
+ beforeEach(() => {
326
+ mock.restore();
327
+ });
328
+
329
+ /** Build a `message.updated` event with optional finalize flag. */
330
+ function makeMessageUpdated(
331
+ overrides: {
332
+ sessionID?: string;
333
+ completed?: boolean;
334
+ input?: number;
335
+ output?: number;
336
+ cacheRead?: number;
337
+ cacheWrite?: number;
338
+ cost?: number;
339
+ modelID?: string;
340
+ } = {},
341
+ ): OpencodeEvent {
342
+ const now = Date.now();
343
+ return {
344
+ type: "message.updated",
345
+ properties: {
346
+ info: {
347
+ id: `msg-${now}`,
348
+ sessionID: overrides.sessionID ?? "sess-abc-123",
349
+ role: "assistant",
350
+ cost: overrides.cost ?? 0.001,
351
+ tokens: {
352
+ input: overrides.input ?? 0,
353
+ output: overrides.output ?? 0,
354
+ reasoning: 0,
355
+ cache: {
356
+ read: overrides.cacheRead ?? 0,
357
+ write: overrides.cacheWrite ?? 0,
358
+ },
359
+ },
360
+ time: overrides.completed ? { created: now, completed: now + 1 } : { created: now },
361
+ parentID: "",
362
+ modelID: overrides.modelID ?? "claude-sonnet-4-5",
363
+ providerID: "anthropic",
364
+ mode: "live",
365
+ path: { cwd: "/", root: "/" },
366
+ } as never,
367
+ },
368
+ };
369
+ }
370
+
371
+ test("finalized message with real tokens → emits context_usage matching the cost row", async () => {
372
+ // Mirrors the E2E evidence: opencode reports `in=12, cache.read=99970,
373
+ // cache.write=104606, out=288` on the FINAL message.updated for the turn.
374
+ const events: OpencodeEvent[] = [
375
+ makeMessageUpdated({
376
+ completed: true,
377
+ input: 12,
378
+ output: 288,
379
+ cacheRead: 99970,
380
+ cacheWrite: 104606,
381
+ }),
382
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
383
+ ];
384
+
385
+ const { emitted, result } = await driveSession(events);
386
+
387
+ const contextEvents = emitted.filter((e) => e.type === "context_usage");
388
+ expect(contextEvents.length).toBe(1);
389
+ const ctx = contextEvents[0];
390
+ if (ctx?.type === "context_usage") {
391
+ // Unified formula: input + cache_read + cache_write + output
392
+ expect(ctx.contextUsedTokens).toBe(12 + 99970 + 104606 + 288);
393
+ expect(ctx.contextFormula).toBe("input-cache-output");
394
+ expect(ctx.outputTokens).toBe(288);
395
+ expect(ctx.contextTotalTokens).toBeGreaterThan(0);
396
+ expect(ctx.contextPercent).toBeGreaterThan(0);
397
+ }
398
+ // The cost row stays consistent — same tokens, single turn.
399
+ expect(result.cost?.inputTokens).toBe(12);
400
+ expect(result.cost?.cacheReadTokens).toBe(99970);
401
+ expect(result.cost?.cacheWriteTokens).toBe(104606);
402
+ expect(result.cost?.outputTokens).toBe(288);
403
+ expect(result.cost?.numTurns).toBe(1);
404
+ });
405
+
406
+ test("non-finalized message.updated (tokens all zero) → NO context_usage emission", async () => {
407
+ // Simulates opencode's intermediate streaming updates that arrive before
408
+ // the model returns usage counts. Pre-fix, these emitted a 0-token snapshot
409
+ // that the runner-side throttle pinned for the rest of the session.
410
+ const events: OpencodeEvent[] = [
411
+ makeMessageUpdated({ completed: false }),
412
+ makeMessageUpdated({ completed: false }),
413
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
414
+ ];
415
+
416
+ const { emitted, result } = await driveSession(events);
417
+
418
+ const contextEvents = emitted.filter((e) => e.type === "context_usage");
419
+ expect(contextEvents.length).toBe(0);
420
+ // Cost accumulator also skipped non-finalized updates.
421
+ expect(result.cost?.numTurns).toBe(0);
422
+ expect(result.cost?.totalCostUsd).toBe(0);
423
+ });
424
+
425
+ test("mix of streaming-zero updates then finalized update → exactly one context_usage from the final", async () => {
426
+ // The realistic opencode event stream: many intermediate zero-token updates
427
+ // followed by a single finalized update. Only the finalized one should
428
+ // produce a context_usage row.
429
+ const events: OpencodeEvent[] = [
430
+ makeMessageUpdated({ completed: false }),
431
+ makeMessageUpdated({ completed: false }),
432
+ makeMessageUpdated({
433
+ completed: true,
434
+ input: 50,
435
+ output: 200,
436
+ cacheRead: 1000,
437
+ cacheWrite: 500,
438
+ }),
439
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
440
+ ];
441
+
442
+ const { emitted, result } = await driveSession(events);
443
+
444
+ const contextEvents = emitted.filter((e) => e.type === "context_usage");
445
+ expect(contextEvents.length).toBe(1);
446
+ if (contextEvents[0]?.type === "context_usage") {
447
+ expect(contextEvents[0].contextUsedTokens).toBe(50 + 1000 + 500 + 200);
448
+ }
449
+ expect(result.cost?.numTurns).toBe(1);
450
+ expect(result.cost?.inputTokens).toBe(50);
451
+ });
452
+
453
+ test("finalized message with all-zero tokens → still no emission (guards against pathological zero turns)", async () => {
454
+ const events: OpencodeEvent[] = [
455
+ makeMessageUpdated({ completed: true }), // all zero tokens
456
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
457
+ ];
458
+
459
+ const { emitted } = await driveSession(events);
460
+ const contextEvents = emitted.filter((e) => e.type === "context_usage");
461
+ expect(contextEvents.length).toBe(0);
462
+ });
463
+ });
464
+
320
465
  // ── DES-300: per-task isolation ────────────────────────────────────────────────
321
466
 
322
467
  describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { scrubOtelException, scrubOtelStatus } from "../otel-impl";
3
+
4
+ const SECRET = "ghp_1234567890abcdefghijklmnopqrstuv";
5
+
6
+ describe("otel-impl secret scrubbing", () => {
7
+ test("scrubs Error messages and stacks before recording exceptions", () => {
8
+ const error = new Error(`request failed with token ${SECRET}`);
9
+ error.stack = `Error: request failed with token ${SECRET}\n at fake`;
10
+
11
+ const scrubbed = scrubOtelException(error);
12
+
13
+ expect(scrubbed).toBeInstanceOf(Error);
14
+ expect((scrubbed as Error).message).not.toContain(SECRET);
15
+ expect((scrubbed as Error).message).toContain("[REDACTED:github_token]");
16
+ expect((scrubbed as Error).stack).not.toContain(SECRET);
17
+ });
18
+
19
+ test("scrubs non-Error exception values", () => {
20
+ const scrubbed = scrubOtelException(`raw failure ${SECRET}`);
21
+
22
+ expect(scrubbed).toBe("raw failure [REDACTED:github_token]");
23
+ });
24
+
25
+ test("scrubs span status messages", () => {
26
+ const status = scrubOtelStatus({
27
+ code: 2,
28
+ message: `worker failed with token ${SECRET}`,
29
+ });
30
+
31
+ expect(status.message).toBe("worker failed with token [REDACTED:github_token]");
32
+ });
33
+ });
@@ -24,8 +24,7 @@ import { handlePagesPublic } from "../http/pages-public";
24
24
  import { getPathSegments, parseQueryParams } from "../http/utils";
25
25
 
26
26
  const TEST_DB_PATH = "./test-pages-view-count.sqlite";
27
- const TEST_PORT = 13095;
28
- const BASE = `http://localhost:${TEST_PORT}`;
27
+ let BASE = "";
29
28
 
30
29
  function createTestServer(): Server {
31
30
  return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
@@ -39,6 +38,23 @@ function createTestServer(): Server {
39
38
  });
40
39
  }
41
40
 
41
+ async function startTestServer(): Promise<Server> {
42
+ const candidateServer = createTestServer();
43
+ await new Promise<void>((resolve, reject) => {
44
+ candidateServer.once("error", reject);
45
+ candidateServer.listen(0, () => {
46
+ const addr = candidateServer.address();
47
+ if (!addr || typeof addr === "string") {
48
+ reject(new Error("Failed to resolve pages view-count test server port"));
49
+ return;
50
+ }
51
+ BASE = `http://localhost:${addr.port}`;
52
+ resolve();
53
+ });
54
+ });
55
+ return candidateServer;
56
+ }
57
+
42
58
  async function getViewCount(id: string, agentId: string): Promise<number> {
43
59
  const res = await fetch(`${BASE}/api/pages/${id}`, {
44
60
  headers: { "X-Agent-ID": agentId },
@@ -50,23 +66,32 @@ async function getViewCount(id: string, agentId: string): Promise<number> {
50
66
 
51
67
  describe("Pages — view_count counter", () => {
52
68
  let server: Server;
69
+ let originalPageSessionSecret: string | undefined;
53
70
  const agentId = crypto.randomUUID();
54
71
  const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
55
72
 
56
73
  beforeAll(async () => {
74
+ originalPageSessionSecret = process.env.PAGE_SESSION_SECRET;
75
+ process.env.PAGE_SESSION_SECRET = "test-view-count-secret";
57
76
  for (const suffix of ["", "-wal", "-shm"]) {
58
77
  try {
59
78
  await unlink(`${TEST_DB_PATH}${suffix}`);
60
79
  } catch {}
61
80
  }
62
81
  initDb(TEST_DB_PATH);
63
- server = createTestServer();
64
- await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
82
+ server = await startTestServer();
65
83
  });
66
84
 
67
85
  afterAll(async () => {
68
- await new Promise<void>((resolve) => server.close(() => resolve()));
86
+ if (server) {
87
+ await new Promise<void>((resolve) => server.close(() => resolve()));
88
+ }
69
89
  closeDb();
90
+ if (originalPageSessionSecret === undefined) {
91
+ delete process.env.PAGE_SESSION_SECRET;
92
+ } else {
93
+ process.env.PAGE_SESSION_SECRET = originalPageSessionSecret;
94
+ }
70
95
  for (const suffix of ["", "-wal", "-shm"]) {
71
96
  try {
72
97
  await unlink(`${TEST_DB_PATH}${suffix}`);
@@ -0,0 +1,18 @@
1
+ // Phase 6: codex adapter reads `reasoning_output_tokens` off `turn.completed`
2
+ // and stuffs it into CostData. Pre-fix the field was read into `lastUsage`
3
+ // but never propagated, so reasoning-model sessions silently under-billed.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { computeCodexCostUsd } from "../../providers/codex-models";
7
+
8
+ describe("codex-models (Phase 6)", () => {
9
+ test("known model still computes a non-zero cost from tokens", () => {
10
+ const usd = computeCodexCostUsd("gpt-5.4", 1_000_000, 0, 0);
11
+ expect(usd).toBeCloseTo(2.5, 5); // 1M input × $2.50/M
12
+ });
13
+
14
+ test("unknown model returns 0 (and logs a warning under the hood)", () => {
15
+ const usd = computeCodexCostUsd("gpt-future-2027", 1_000_000, 0, 1_000_000);
16
+ expect(usd).toBe(0);
17
+ });
18
+ });
@@ -0,0 +1,74 @@
1
+ // Phase 3 fix — regression guard that OpencodeSession stamps `provider:
2
+ // "opencode"` on every CostData it emits. Without this tag the API server
3
+ // recompute branch in src/http/session-data.ts falls through to
4
+ // costSource='harness' instead of engaging the pricing-table lookup, so a
5
+ // perfectly-priced model still renders as un-priced in the dashboard.
6
+ //
7
+ // Mirrors the narrow, single-purpose shape of src/tests/providers/codex-cost.test.ts.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import type { Event as OpencodeEvent } from "@opencode-ai/sdk";
11
+ import { OpencodeSession } from "../../providers/opencode-adapter";
12
+ import type { ProviderEvent } from "../../providers/types";
13
+
14
+ function makeSession(): {
15
+ session: OpencodeSession;
16
+ events: ProviderEvent[];
17
+ } {
18
+ const sessionId = "sess-cost-test";
19
+ const session = new OpencodeSession(
20
+ sessionId,
21
+ { url: "http://127.0.0.1:0", close: () => {} },
22
+ "openrouter/deepseek/deepseek-v4-flash",
23
+ "agent-1",
24
+ "task-1",
25
+ "/tmp/opencode-agent.md",
26
+ "/tmp/opencode-config.json",
27
+ "/tmp/opencode-data",
28
+ );
29
+ const events: ProviderEvent[] = [];
30
+ session.onEvent((e) => events.push(e));
31
+ return { session, events };
32
+ }
33
+
34
+ describe("OpencodeSession — provider tag on CostData", () => {
35
+ test("session.idle → emitted `result.cost.provider === 'opencode'`", async () => {
36
+ const { session, events } = makeSession();
37
+
38
+ // Drive the SSE event that causes OpencodeSession to build + emit CostData.
39
+ session.handleOpencodeEvent({
40
+ type: "session.idle",
41
+ properties: { sessionID: "sess-cost-test" },
42
+ } as unknown as OpencodeEvent);
43
+
44
+ const result = await session.waitForCompletion();
45
+
46
+ const resultEvent = events.find((e) => e.type === "result");
47
+ expect(resultEvent).toBeDefined();
48
+ if (resultEvent?.type === "result") {
49
+ // The load-bearing assertion. Phase 2's API recompute path keys off
50
+ // exactly this field; emitting CostData without it silently disables
51
+ // pricing-table tagging for the entire opencode provider.
52
+ expect(resultEvent.cost.provider).toBe("opencode");
53
+ }
54
+ expect(result.cost?.provider).toBe("opencode");
55
+ });
56
+
57
+ test("session.error → emitted `result.cost.provider === 'opencode'` on error path too", async () => {
58
+ const { session, events } = makeSession();
59
+
60
+ session.handleOpencodeEvent({
61
+ type: "session.error",
62
+ properties: {
63
+ sessionID: "sess-cost-test",
64
+ error: { message: "boom" },
65
+ },
66
+ } as unknown as OpencodeEvent);
67
+
68
+ const result = await session.waitForCompletion();
69
+ // The error-path also routes through buildCostData; same regression risk.
70
+ expect(result.cost?.provider).toBe("opencode");
71
+ const errEvent = events.find((e) => e.type === "error");
72
+ expect(errEvent).toBeDefined();
73
+ });
74
+ });