diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:57:41 +0900 |
| commit | d27d97bb3aa0c13f4032bab54703ebb9e1c84c81 (patch) | |
| tree | b5bcfed65be5a4d27a7bbe6b46e338dcd489c2e0 /packages/core/tests | |
| parent | 3671b82cc624117476e30b95eaf7d2bc3b34ae28 (diff) | |
| parent | c1439ea8c677ddfd11c219de39c3e77c7e297a9b (diff) | |
| download | dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.tar.gz dispatch-d27d97bb3aa0c13f4032bab54703ebb9e1c84c81.zip | |
Merge branch 'dev' into u3/agent-effort-level
# Conflicts:
# packages/api/tests/agent-manager.test.ts
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/db/chunks.db.test.ts | 336 | ||||
| -rw-r--r-- | packages/core/tests/models/catalog.test.ts | 158 |
2 files changed, 494 insertions, 0 deletions
diff --git a/packages/core/tests/db/chunks.db.test.ts b/packages/core/tests/db/chunks.db.test.ts new file mode 100644 index 0000000..4f7d517 --- /dev/null +++ b/packages/core/tests/db/chunks.db.test.ts @@ -0,0 +1,336 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChunkRowDraft, UsageData } from "../../src/types/index.js"; + +/** + * Internal row shape — matches the production `chunks` table columns. + * Kept loose at the `query()` boundary to mirror bun:sqlite's dynamic + * return type. + */ +interface ChunkRecord { + id: string; + tab_id: string; + seq: number; + turn_id: string; + step: number; + role: string; + type: string; + data_json: string; + created_at: number; +} + +/** + * In-memory fake of `bun:sqlite`'s Database implementing only the queries + * `chunks.ts` actually issues. Same approach as `tabs.test.ts`: match exact + * normalized query strings as fixed branches (no SQL parser), so a query-string + * change fails loudly as "unsupported" instead of silently returning wrong data. + * + * This lets the DB-backed `getChunksForTab` / `getTotalChunkCount` / + * `getUsageStatsForTab` logic run under vitest, where `bun:sqlite` can't load. + */ +class FakeDatabase { + rows: ChunkRecord[] = []; + private idCounter = 0; + + query(sql: string): { + all: (params?: Record<string, unknown>) => unknown[]; + get: (params?: Record<string, unknown>) => unknown; + run: (params?: Record<string, unknown>) => void; + } { + return { + all: (params) => this.execSelect(sql, params), + get: (params) => this.execSelect(sql, params)[0] ?? null, + run: (params) => { + this.execMutation(sql, params); + }, + }; + } + + /** bun:sqlite's `db.transaction(fn)` returns a callable that runs `fn`. */ + transaction(fn: () => void): () => void { + return () => { + fn(); + }; + } + + private execSelect(sql: string, params?: Record<string, unknown>): unknown[] { + const norm = sql.replace(/\s+/g, " ").trim(); + const tabId = params?.$tabId as string | undefined; + const forTab = this.rows.filter((r) => r.tab_id === tabId); + const visible = forTab.filter((r) => r.type !== "usage"); + + // appendChunks: next-seq lookup (counts ALL rows, incl. usage) + if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") { + const seqs = forTab.map((r) => r.seq); + return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }]; + } + + // getChunksForTab — no options (usage excluded) + if ( + norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC" + ) { + return [...visible].sort((a, b) => a.seq - b.seq); + } + + // getChunksForTab — before + limit (usage excluded) + if ( + norm === + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit" + ) { + const before = params?.$before as number; + const limit = params?.$limit as number; + return visible + .filter((r) => r.seq < before) + .sort((a, b) => b.seq - a.seq) + .slice(0, limit); + } + + // getChunksForTab — before only (usage excluded) + if ( + norm === + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC" + ) { + const before = params?.$before as number; + return visible.filter((r) => r.seq < before).sort((a, b) => b.seq - a.seq); + } + + // getChunksForTab — limit only (usage excluded) + if ( + norm === + "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit" + ) { + const limit = params?.$limit as number; + return [...visible].sort((a, b) => b.seq - a.seq).slice(0, limit); + } + + // getTotalChunkCount (usage excluded) + if (norm === "SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") { + return [{ count: visible.length }]; + } + + // getUsageStatsForTab: usage rows only, in seq order + if ( + norm === + "SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC" + ) { + return forTab + .filter((r) => r.type === "usage") + .sort((a, b) => a.seq - b.seq) + .map((r) => ({ data_json: r.data_json })); + } + + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); + } + + private execMutation(sql: string, params?: Record<string, unknown>): void { + const norm = sql.replace(/\s+/g, " ").trim(); + + // appendChunks: single-row insert + if ( + norm === + "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)" + ) { + this.rows.push({ + id: (params?.$id as string) ?? `c${this.idCounter++}`, + tab_id: params?.$tabId as string, + seq: params?.$seq as number, + turn_id: params?.$turnId as string, + step: (params?.$step as number) ?? 0, + role: params?.$role as string, + type: params?.$type as string, + data_json: params?.$dataJson as string, + created_at: (params?.$now as number) ?? 0, + }); + return; + } + + throw new Error(`FakeDatabase: unsupported mutation: ${norm}`); + } +} + +let fakeDb: FakeDatabase; + +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => fakeDb), +})); + +const { appendChunks, getChunksForTab, getTotalChunkCount, getUsageStatsForTab } = await import( + "../../src/db/chunks.js" +); + +function usageDraft(turnId: string, u: UsageData): ChunkRowDraft { + return { turnId, step: 0, role: "assistant", type: "usage", data: u }; +} + +beforeAll(() => { + fakeDb = new FakeDatabase(); +}); + +beforeEach(() => { + fakeDb.rows = []; +}); + +// --------------------------------------------------------------------------- +// usage chunk persistence + side-channel invariants +// --------------------------------------------------------------------------- +describe("usage chunk rows (DB-backed)", () => { + const TAB = "tab-usage"; + + it("persists usage rows alongside content rows with contiguous seqs", () => { + appendChunks(TAB, [ + { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } }, + { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } }, + usageDraft("t1", { + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 90, + }), + ]); + // All three rows landed with contiguous seqs. + expect(fakeDb.rows.map((r) => r.seq)).toEqual([0, 1, 2]); + expect(fakeDb.rows.map((r) => r.type)).toEqual(["text", "text", "usage"]); + }); + + it("excludes usage rows from getChunksForTab (all variants)", () => { + appendChunks(TAB, [ + { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } }, + usageDraft("t1", { + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 90, + }), + { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } }, + usageDraft("t1", { + inputTokens: 200, + outputTokens: 20, + cacheReadTokens: 150, + cacheWriteTokens: 0, + }), + ]); + + // no options + const all = getChunksForTab(TAB); + expect(all.every((r) => r.type !== "usage")).toBe(true); + expect(all.map((r) => r.type)).toEqual(["text", "text"]); + + // limit only + const limited = getChunksForTab(TAB, { limit: 10 }); + expect(limited.every((r) => r.type !== "usage")).toBe(true); + expect(limited).toHaveLength(2); + + // before only — `before` is a seq cursor; usage seqs must never surface + const before = getChunksForTab(TAB, { before: 100 }); + expect(before.every((r) => r.type !== "usage")).toBe(true); + expect(before).toHaveLength(2); + + // before + limit + const bl = getChunksForTab(TAB, { before: 100, limit: 10 }); + expect(bl.every((r) => r.type !== "usage")).toBe(true); + expect(bl).toHaveLength(2); + }); + + it("excludes usage rows from getTotalChunkCount", () => { + appendChunks(TAB, [ + { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } }, + { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } }, + usageDraft("t1", { + inputTokens: 100, + outputTokens: 10, + cacheReadTokens: 0, + cacheWriteTokens: 90, + }), + ]); + // 3 rows total, but only 2 visible. + expect(getTotalChunkCount(TAB)).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// getUsageStatsForTab — backend aggregate +// --------------------------------------------------------------------------- +describe("getUsageStatsForTab", () => { + const TAB = "tab-agg"; + + it("returns null when the tab has no usage rows", () => { + appendChunks(TAB, [ + { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } }, + ]); + expect(getUsageStatsForTab(TAB)).toBeNull(); + }); + + it("sums cumulative tokens, counts requests, and reports the last request's split", () => { + appendChunks(TAB, [ + usageDraft("t1", { + inputTokens: 1000, + outputTokens: 40, + cacheReadTokens: 0, + cacheWriteTokens: 900, + }), + usageDraft("t1", { + inputTokens: 1200, + outputTokens: 60, + cacheReadTokens: 1000, + cacheWriteTokens: 100, + }), + ]); + + const stats = getUsageStatsForTab(TAB); + expect(stats).not.toBeNull(); + expect(stats?.requests).toBe(2); + expect(stats?.inputTokens).toBe(2200); + expect(stats?.outputTokens).toBe(100); + expect(stats?.cacheReadTokens).toBe(1000); + expect(stats?.cacheWriteTokens).toBe(1000); + // `last` = the most recent (highest-seq) usage row. + expect(stats?.last).toEqual({ + inputTokens: 1200, + outputTokens: 60, + cacheReadTokens: 1000, + cacheWriteTokens: 100, + }); + }); + + it("is structurally identical to the frontend CacheStats shape (seeds directly)", () => { + appendChunks(TAB, [ + usageDraft("t1", { + inputTokens: 5, + outputTokens: 1, + cacheReadTokens: 2, + cacheWriteTokens: 3, + }), + ]); + const stats = getUsageStatsForTab(TAB); + expect(Object.keys(stats ?? {}).sort()).toEqual( + [ + "cacheReadTokens", + "cacheWriteTokens", + "inputTokens", + "last", + "outputTokens", + "requests", + ].sort(), + ); + }); + + it("is scoped per tab", () => { + appendChunks("tab-a", [ + usageDraft("t1", { + inputTokens: 10, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + ]); + appendChunks("tab-b", [ + usageDraft("t2", { + inputTokens: 20, + outputTokens: 2, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }), + ]); + expect(getUsageStatsForTab("tab-a")?.inputTokens).toBe(10); + expect(getUsageStatsForTab("tab-b")?.inputTokens).toBe(20); + }); +}); diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts new file mode 100644 index 0000000..51043e6 --- /dev/null +++ b/packages/core/tests/models/catalog.test.ts @@ -0,0 +1,158 @@ +import { existsSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetCatalogCacheForTests, + getModelsCatalog, + resolveContextLimit, +} from "../../src/models/catalog.js"; + +const CACHE_PATH = "/tmp/dispatch/models-dev.json"; + +// A trimmed models.dev-shaped catalog covering the providers we support. +const CATALOG = { + anthropic: { + id: "anthropic", + models: { + "claude-sonnet-4-5": { limit: { context: 200000, output: 64000 } }, + "claude-sonnet-4-6": { limit: { context: 1000000, output: 64000 } }, + }, + }, + opencode: { + id: "opencode", + models: { + "glm-4-6": { limit: { context: 131072, output: 8192 } }, + }, + }, +}; + +function mockFetchOnce(catalog: unknown, ok = true, status = 200) { + const fn = vi.fn(() => + Promise.resolve({ + ok, + status, + text: () => Promise.resolve(JSON.stringify(catalog)), + } as Response), + ); + vi.stubGlobal("fetch", fn); + return fn; +} + +beforeEach(() => { + __resetCatalogCacheForTests(); + if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH); + delete process.env.DISPATCH_DISABLE_MODELS_FETCH; +}); + +afterEach(() => { + vi.unstubAllGlobals(); + if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH); +}); + +describe("resolveContextLimit", () => { + it("resolves a known anthropic model to its context window", async () => { + mockFetchOnce(CATALOG); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBe(1000000); + }); + + it("maps opencode-anthropic to the anthropic catalog, then opencode fallback", async () => { + mockFetchOnce(CATALOG); + // Present in the anthropic catalog. + expect(await resolveContextLimit("opencode-anthropic", "claude-sonnet-4-5")).toBe(200000); + // Absent in anthropic, found in the opencode gateway catalog. + expect(await resolveContextLimit("opencode-anthropic", "glm-4-6")).toBe(131072); + }); + + it("returns null for an unknown model id", async () => { + mockFetchOnce(CATALOG); + expect(await resolveContextLimit("anthropic", "no-such-model")).toBeNull(); + }); + + it("returns null for an unsupported provider (no network needed)", async () => { + const fetchFn = mockFetchOnce(CATALOG); + expect(await resolveContextLimit("google", "gemini-2.5-pro")).toBeNull(); + expect(await resolveContextLimit("anthropic", "")).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("returns null when the model has no positive context limit", async () => { + mockFetchOnce({ + anthropic: { id: "anthropic", models: { broken: { limit: { context: 0 } } } }, + }); + expect(await resolveContextLimit("anthropic", "broken")).toBeNull(); + }); + + it("does not throw on a malformed provider entry missing `models`", async () => { + // A provider object without a `models` map must degrade to null, not crash. + mockFetchOnce({ anthropic: { id: "anthropic" } }); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull(); + }); + + it("does not throw when limit/context fields are absent", async () => { + mockFetchOnce({ anthropic: { id: "anthropic", models: { m: {} } } }); + expect(await resolveContextLimit("anthropic", "m")).toBeNull(); + }); +}); + +describe("getModelsCatalog caching", () => { + it("fetches once and serves the in-process memo on subsequent calls", async () => { + const fetchFn = mockFetchOnce(CATALOG); + await resolveContextLimit("anthropic", "claude-sonnet-4-5"); + await resolveContextLimit("anthropic", "claude-sonnet-4-6"); + await getModelsCatalog(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("reuses a fresh disk cache without re-fetching across processes", async () => { + // Simulate another process having written a fresh cache. + writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8"); + const fetchFn = vi.fn(() => Promise.reject(new Error("network should not be hit"))); + vi.stubGlobal("fetch", fetchFn); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("falls back to a STALE disk cache when the network fails", async () => { + writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8"); + // Age the cache well past the TTL so the fetch path is taken. + const old = Date.now() / 1000 - 3600; + utimesSync(CACHE_PATH, old, old); + const fetchFn = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchFn); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000); + expect(fetchFn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + + it("returns null when fetch fails and no cache exists", async () => { + const fetchFn = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchFn); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull(); + warn.mockRestore(); + }); + + it("does not hit the network when DISPATCH_DISABLE_MODELS_FETCH is set", async () => { + process.env.DISPATCH_DISABLE_MODELS_FETCH = "1"; + const fetchFn = vi.fn(() => Promise.reject(new Error("should not fetch"))); + vi.stubGlobal("fetch", fetchFn); + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("memoizes the fallback after a failed fetch so it does not re-hit the network", async () => { + const fetchFn = vi.fn(() => Promise.reject(new Error("offline"))); + vi.stubGlobal("fetch", fetchFn); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // First lookup triggers the (failing) fetch. + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull(); + // Subsequent lookups within the penalty window must NOT re-fetch. + expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBeNull(); + await getModelsCatalog(); + expect(fetchFn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); |
