summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-03 08:24:40 +0900
committerAdam Malczewski <[email protected]>2026-06-03 08:24:40 +0900
commitbc3ecbe7b72f6da6ed36d0cea5a66de1c440269a (patch)
tree17e84ebf8d83c51a7a50312c256372a86e38b92a /packages/core/tests
parentb26821ead97b986f886065b20d3dbde8283daa64 (diff)
parentae672fd4f5542a2c217cf97657bf81eeebdaabbd (diff)
downloaddispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.tar.gz
dispatch-bc3ecbe7b72f6da6ed36d0cea5a66de1c440269a.zip
Merge branch 'dev' into cmp7/compaction-tool
# Conflicts: # packages/frontend/src/lib/components/ChatInput.svelte
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/agent/agent.test.ts98
-rw-r--r--packages/core/tests/models/attachments.test.ts136
-rw-r--r--packages/core/tests/models/catalog.test.ts75
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
4 files changed, 623 insertions, 3 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index d8edec7..f4b33cc 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -1544,4 +1544,102 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
effort: "xhigh",
});
});
+
+ describe("multimodal user content", () => {
+ it("emits ordered text + image parts to the model when content is provided", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("here is image A: [image]", {
+ content: [
+ { type: "text", text: "here is image A: " },
+ { type: "attachment", mediaType: "image/png", data: "QQ==" },
+ ],
+ })) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+ const userMsg = messages.find((m) => m.role === "user");
+ expect(userMsg).toBeDefined();
+ // Multimodal turn → content is an ordered parts array, not a string.
+ expect(Array.isArray(userMsg?.content)).toBe(true);
+ const parts = userMsg?.content as Array<Record<string, unknown>>;
+ expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " });
+ expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" });
+ expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ==");
+ });
+
+ it("emits a FilePart for a PDF attachment with its filename", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("see [pdf]", {
+ content: [
+ { type: "text", text: "see " },
+ { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
+ ],
+ })) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+ const userMsg = messages.find((m) => m.role === "user");
+ const parts = userMsg?.content as Array<Record<string, unknown>>;
+ const filePart = parts.find((p) => p.type === "file");
+ expect(filePart).toMatchObject({
+ type: "file",
+ mediaType: "application/pdf",
+ filename: "doc.pdf",
+ });
+ expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ==");
+ });
+
+ it("persists the user turn as text only (no content) for history", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("look: [image]", {
+ content: [
+ { type: "text", text: "look: " },
+ { type: "attachment", mediaType: "image/png", data: "QQ==" },
+ ],
+ })) {
+ // consume
+ }
+
+ // The in-memory user message keeps the text chunk for the render/persist
+ // path; the ephemeral `content` rides alongside it but isn't a chunk.
+ const userMsg = agent.messages.find((m) => m.role === "user");
+ expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]);
+ });
+
+ it("falls back to a plain string when content has no attachment", async () => {
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
+ );
+
+ const agent = new Agent(makeConfig());
+ for await (const _ of agent.run("plain text", {
+ content: [{ type: "text", text: "plain text" }],
+ })) {
+ // consume
+ }
+
+ const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+ const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
+ const userMsg = messages.find((m) => m.role === "user");
+ // No attachment → plain string content (byte-identical to text-only path).
+ expect(typeof userMsg?.content).toBe("string");
+ expect(userMsg?.content).toBe("plain text");
+ });
+ });
});
diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts
new file mode 100644
index 0000000..11a9f82
--- /dev/null
+++ b/packages/core/tests/models/attachments.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+import {
+ base64ByteLength,
+ isAcceptedAttachmentMediaType,
+ isImageMediaType,
+ isPdfMediaType,
+ MAX_ATTACHMENTS,
+ MAX_IMAGE_BYTES,
+ MAX_PDF_BYTES,
+ MAX_TOTAL_ATTACHMENT_BYTES,
+ validateUserContent,
+} from "../../src/models/attachments.js";
+import type { UserContentPart } from "../../src/types/index.js";
+
+/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */
+function base64OfBytes(bytes: number): string {
+ // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output.
+ const groups = Math.ceil(bytes / 3);
+ return "A".repeat(groups * 4);
+}
+
+function imagePart(data: string, mediaType = "image/png"): UserContentPart {
+ return { type: "attachment", mediaType, data };
+}
+
+describe("media-type predicates", () => {
+ it("classifies image types", () => {
+ expect(isImageMediaType("image/png")).toBe(true);
+ expect(isImageMediaType("image/jpeg")).toBe(true);
+ expect(isImageMediaType("image/webp")).toBe(true);
+ expect(isImageMediaType("image/gif")).toBe(true);
+ expect(isImageMediaType("application/pdf")).toBe(false);
+ expect(isImageMediaType("image/svg+xml")).toBe(false);
+ });
+
+ it("classifies pdf + accepted types", () => {
+ expect(isPdfMediaType("application/pdf")).toBe(true);
+ expect(isPdfMediaType("image/png")).toBe(false);
+ expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true);
+ expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true);
+ expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false);
+ });
+});
+
+describe("base64ByteLength", () => {
+ it("computes decoded length without padding", () => {
+ // "AAAA" → 3 bytes.
+ expect(base64ByteLength("AAAA")).toBe(3);
+ });
+
+ it("accounts for padding", () => {
+ // "QQ==" → 1 byte ("A").
+ expect(base64ByteLength("QQ==")).toBe(1);
+ // "QUI=" → 2 bytes ("AB").
+ expect(base64ByteLength("QUI=")).toBe(2);
+ });
+
+ it("tolerates a data: URI prefix and whitespace", () => {
+ expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3);
+ expect(base64ByteLength("AA\nAA")).toBe(3);
+ });
+
+ it("returns 0 for empty input", () => {
+ expect(base64ByteLength("")).toBe(0);
+ expect(base64ByteLength(" ")).toBe(0);
+ });
+});
+
+describe("validateUserContent", () => {
+ it("accepts a small image and ignores text parts", () => {
+ const content: UserContentPart[] = [
+ { type: "text", text: "hi" },
+ imagePart(base64OfBytes(1024)),
+ ];
+ expect(validateUserContent(content)).toEqual({ ok: true, errors: [] });
+ });
+
+ it("accepts an empty / text-only content list", () => {
+ expect(validateUserContent([]).ok).toBe(true);
+ expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true);
+ });
+
+ it("rejects an unsupported media type", () => {
+ const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]);
+ expect(res.ok).toBe(false);
+ expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" });
+ });
+
+ it("rejects an oversized image but allows a PDF of the same size", () => {
+ const big = base64OfBytes(MAX_IMAGE_BYTES + 3);
+ const imgRes = validateUserContent([imagePart(big, "image/png")]);
+ expect(imgRes.ok).toBe(false);
+ expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true);
+
+ // Same byte size as a PDF is fine (PDF limit is much higher).
+ const pdfRes = validateUserContent([imagePart(big, "application/pdf")]);
+ expect(pdfRes.ok).toBe(true);
+ });
+
+ it("rejects an oversized PDF", () => {
+ const res = validateUserContent([
+ imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"),
+ ]);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true);
+ });
+
+ it("rejects an empty attachment payload", () => {
+ const res = validateUserContent([imagePart("", "image/png")]);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.code === "empty")).toBe(true);
+ });
+
+ it("rejects too many attachments", () => {
+ const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () =>
+ imagePart(base64OfBytes(8)),
+ );
+ const res = validateUserContent(content);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.code === "too-many")).toBe(true);
+ });
+
+ it("rejects when the total payload exceeds the request ceiling", () => {
+ // Several individually-legal PDFs that together exceed the total cap.
+ const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3);
+ const content: UserContentPart[] = [
+ imagePart(base64OfBytes(each), "application/pdf"),
+ imagePart(base64OfBytes(each), "application/pdf"),
+ imagePart(base64OfBytes(each), "application/pdf"),
+ imagePart(base64OfBytes(each), "application/pdf"),
+ ];
+ const res = validateUserContent(content);
+ expect(res.ok).toBe(false);
+ expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true);
+ });
+});
diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts
index 51043e6..f4bddc2 100644
--- a/packages/core/tests/models/catalog.test.ts
+++ b/packages/core/tests/models/catalog.test.ts
@@ -4,6 +4,7 @@ import {
__resetCatalogCacheForTests,
getModelsCatalog,
resolveContextLimit,
+ resolveModelCapabilities,
} from "../../src/models/catalog.js";
const CACHE_PATH = "/tmp/dispatch/models-dev.json";
@@ -13,14 +14,30 @@ const CATALOG = {
anthropic: {
id: "anthropic",
models: {
- "claude-sonnet-4-5": { limit: { context: 200000, output: 64000 } },
- "claude-sonnet-4-6": { limit: { context: 1000000, output: 64000 } },
+ "claude-sonnet-4-5": {
+ limit: { context: 200000, output: 64000 },
+ modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+ },
+ "claude-sonnet-4-6": {
+ limit: { context: 1000000, output: 64000 },
+ modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+ },
+ // A text-only model: definitively no image/pdf input.
+ "text-only-model": {
+ limit: { context: 100000, output: 8192 },
+ modalities: { input: ["text"], output: ["text"] },
+ },
+ // An entry predating the modalities field → capability unknown.
+ "legacy-model": { limit: { context: 100000, output: 8192 } },
},
},
opencode: {
id: "opencode",
models: {
- "glm-4-6": { limit: { context: 131072, output: 8192 } },
+ "glm-4-6": {
+ limit: { context: 131072, output: 8192 },
+ modalities: { input: ["text", "image"], output: ["text"] },
+ },
},
},
};
@@ -156,3 +173,55 @@ describe("getModelsCatalog caching", () => {
warn.mockRestore();
});
});
+
+describe("resolveModelCapabilities", () => {
+ it("reports image + pdf for a vision model", async () => {
+ mockFetchOnce(CATALOG);
+ expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({
+ image: true,
+ pdf: true,
+ });
+ });
+
+ it("reports image-only for a model whose modalities omit pdf", async () => {
+ mockFetchOnce(CATALOG);
+ // glm-4-6 lists image but not pdf (resolved via the opencode fallback).
+ expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({
+ image: true,
+ pdf: false,
+ });
+ });
+
+ it("reports a definitive no for a text-only model", async () => {
+ mockFetchOnce(CATALOG);
+ expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({
+ image: false,
+ pdf: false,
+ });
+ });
+
+ it("returns null (unknown) for an entry without modalities", async () => {
+ mockFetchOnce(CATALOG);
+ expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull();
+ });
+
+ it("returns null (unknown) for an unknown model id", async () => {
+ mockFetchOnce(CATALOG);
+ expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull();
+ });
+
+ it("returns null for an unsupported provider without hitting the network", async () => {
+ const fetchFn = mockFetchOnce(CATALOG);
+ expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull();
+ expect(await resolveModelCapabilities("anthropic", "")).toBeNull();
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+
+ it("returns null (unknown) when the catalog is offline with no cache", async () => {
+ const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
+ vi.stubGlobal("fetch", fetchFn);
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull();
+ warn.mockRestore();
+ });
+});
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
new file mode 100644
index 0000000..643e30e
--- /dev/null
+++ b/packages/core/tests/tools/key-usage.test.ts
@@ -0,0 +1,317 @@
+import { describe, expect, it, vi } from "vitest";
+
+// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
+// transitively imports `db/index.js` (top-level `import { Database } from
+// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
+// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
+// is enough to let the import chain resolve.
+vi.mock("../../src/db/index.js", () => ({
+ getDatabase: vi.fn(() => {
+ throw new Error("db not available in this test");
+ }),
+}));
+
+import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
+import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
+import {
+ createKeyUsageTool,
+ formatKeyUsage,
+ type KeyUsageCallbacks,
+} from "../../src/tools/key-usage.js";
+import type { KeyDefinition, KeyState } from "../../src/types/index.js";
+
+// ─── Builders ─────────────────────────────────────────────────
+
+function keyState(
+ def: Partial<KeyDefinition> & { id: string; provider: string },
+ overrides: Partial<Omit<KeyState, "definition">> = {},
+): KeyState {
+ return {
+ definition: { base_url: "https://example.test", ...def },
+ status: "active",
+ ...overrides,
+ };
+}
+
+function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
+ return {
+ id,
+ label: id,
+ source,
+ credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
+ };
+}
+
+/** Build the tool with explicit stub fetchers — no network, no DB. */
+function buildTool(opts: {
+ keys: KeyState[];
+ accounts?: ClaudeAccount[];
+ anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
+ opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
+}) {
+ const callbacks: KeyUsageCallbacks = {
+ listKeys: () => opts.keys,
+ listClaudeAccounts: () => opts.accounts ?? [],
+ fetchAnthropicUsage: opts.anthropic ?? (async () => null),
+ fetchOpencodeUsage: opts.opencode ?? (async () => null),
+ };
+ return createKeyUsageTool(callbacks);
+}
+
+const HOUR = 3_600_000;
+
+describe("key_usage tool", () => {
+ it("reports all keys when no key_id is given", async () => {
+ const reset5h = Date.now() + 2 * HOUR;
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ accounts: [account("claude-max", "/creds/max.json")],
+ anthropic: async () => ({
+ source: "live",
+ report: {
+ fiveHour: { utilization: 0.25, resetsAt: reset5h },
+ sevenDay: { utilization: 0.6 },
+ },
+ }),
+ opencode: async () => ({
+ fiveHour: { utilization: 0.1 },
+ weekly: { utilization: 0.4 },
+ monthly: { utilization: 0.7 },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ // Both keys present with providers.
+ expect(out).toContain("[claude-max] provider: anthropic");
+ expect(out).toContain("[opencode-1] provider: opencode-go");
+ // Remaining = (1 - utilization) * 100.
+ expect(out).toContain("5-hour: 75% remaining");
+ expect(out).toContain("week: 40% remaining");
+ expect(out).toContain("5-hour: 90% remaining");
+ expect(out).toContain("week: 60% remaining");
+ expect(out).toContain("month: 30% remaining");
+ expect(out).toContain("data: live (fetched just now)");
+ });
+
+ it("filters to a single key when key_id is given and does not fetch others", async () => {
+ const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "live",
+ report: { fiveHour: { utilization: 0.2 } },
+ }),
+ opencode: opencodeFetch,
+ });
+
+ const out = await tool.execute({ key_id: "claude-max" });
+
+ expect(out).toContain("[claude-max] provider: anthropic");
+ expect(out).not.toContain("opencode-1");
+ expect(opencodeFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns a helpful error for an unknown key_id", async () => {
+ const tool = buildTool({
+ keys: [
+ keyState({ id: "claude-max", provider: "anthropic" }),
+ keyState({ id: "opencode-1", provider: "opencode-go" }),
+ ],
+ });
+
+ const out = await tool.execute({ key_id: "nope" });
+
+ expect(out).toContain('no key found with id "nope"');
+ expect(out).toContain("claude-max");
+ expect(out).toContain("opencode-1");
+ });
+
+ it("reports cached data with the source's last-fetched timestamp", async () => {
+ const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "cache",
+ cachedAt,
+ report: { fiveHour: { utilization: 0.5 } },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
+ expect(out).toContain("5-hour: 50% remaining");
+ });
+
+ it("omits the month window for anthropic (no monthly bucket)", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => ({
+ source: "live",
+ report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("5-hour:");
+ expect(out).toContain("week:");
+ expect(out).not.toContain("month:");
+ });
+
+ it("includes the month window for opencode-go", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => ({
+ fiveHour: { utilization: 0.1 },
+ weekly: { utilization: 0.2 },
+ monthly: { utilization: 0.3 },
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("month: 70% remaining");
+ });
+
+ it("surfaces exhausted status with the last error", async () => {
+ const exhaustedAt = Date.now() - HOUR;
+ const tool = buildTool({
+ keys: [
+ keyState(
+ { id: "opencode-1", provider: "opencode-go" },
+ { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
+ ),
+ ],
+ opencode: async () => null,
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("status: EXHAUSTED");
+ expect(out).toContain("last error: 429 rate limit exceeded");
+ });
+
+ it("flags providers without usage support", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "gem", provider: "google" })],
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("[gem] provider: google");
+ expect(out).toContain("not supported");
+ });
+
+ it("reports unavailable when a supported provider returns no usage", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [account("claude-max")],
+ anthropic: async () => null,
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("usage: unavailable");
+ expect(out).toContain("no cached usage");
+ });
+
+ it("reports unavailable for anthropic keys with no account credentials", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "claude-max", provider: "anthropic" })],
+ accounts: [],
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("no Claude account credentials available");
+ });
+
+ it("treats a fetcher that throws as unavailable (does not crash)", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => {
+ throw new Error("network down");
+ },
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("usage: unavailable");
+ });
+
+ it("reports when no keys are configured at all", async () => {
+ const tool = buildTool({ keys: [] });
+ const out = await tool.execute({});
+ expect(out).toBe("No API keys are configured.");
+ });
+
+ it("clamps out-of-range utilization to 0–100%", async () => {
+ const tool = buildTool({
+ keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
+ opencode: async () => ({
+ fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
+ weekly: { utilization: -0.5 }, // negative → 100% remaining
+ }),
+ });
+
+ const out = await tool.execute({});
+
+ expect(out).toContain("5-hour: 0% remaining");
+ expect(out).toContain("week: 100% remaining");
+ });
+});
+
+describe("formatKeyUsage (pure)", () => {
+ const now = Date.UTC(2025, 5, 1, 12, 0, 0);
+
+ it("formats reset timestamps with ISO + relative time", () => {
+ const out = formatKeyUsage(
+ [
+ {
+ keyId: "claude-max",
+ provider: "anthropic",
+ status: "active",
+ dataSource: "live",
+ windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
+ },
+ ],
+ now,
+ );
+
+ expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
+ });
+
+ it("renders a past reset/exhaustion time as 'ago'", () => {
+ const out = formatKeyUsage(
+ [
+ {
+ keyId: "opencode-1",
+ provider: "opencode-go",
+ status: "exhausted",
+ exhaustedAt: now - 2 * HOUR,
+ lastError: "boom",
+ windows: [],
+ },
+ ],
+ now,
+ );
+
+ expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
+ expect(out).toContain("last error: boom");
+ });
+
+ it("returns a friendly message when no entries match", () => {
+ expect(formatKeyUsage([], now)).toBe("No API keys matched.");
+ });
+});