diff --git a/.env.example b/.env.example index 1f516a86f..0d60d727c 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,14 @@ PRIVY_WEBHOOK_SECRET=replace_with_strong_random_secret # Anthropic API Key (for Claude - used by AI App Builder) # Get from: https://console.anthropic.com/settings/keys ANTHROPIC_API_KEY=sk-ant-your_anthropic_key_here +# Default Anthropic extended-thinking budget (tokens) when a cloud agent character does not set +# user_characters.settings.anthropicThinkingBudgetTokens. Per-agent: set that JSON key (integer ≥ 0; 0 = off). +# Optional ANTHROPIC_COT_BUDGET_MAX caps any effective budget (character or default). +# Why not from API bodies: untrusted clients must not raise thinking cost; agents own policy via stored settings. +# Unset, empty, or 0 = no default budget (agent can still set a positive per-character budget unless max is 0). +# ANTHROPIC_COT_BUDGET=1024 +# ANTHROPIC_COT_BUDGET_MAX=8192 +# See docs/anthropic-cot-budget.md # ============================================================================ # OpenAI API Key (for direct OpenAI access and ElizaOS) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b40f94a06..6ca8f6343 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,8 @@ jobs: run: bun run lint - name: Run typecheck run: bun run check-types + - name: Run test project typecheck + run: bun run check-types:tests unit-tests: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..343fcd393 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable engineering changes to this repository are recorded here. For **product-facing** release notes on the docs site, see `packages/content/changelog.mdx`. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **Per-agent Anthropic extended thinking** — `user_characters.settings.anthropicThinkingBudgetTokens` (integer ≥ 0) controls thinking for **MCP** and **A2A** agent chat when the model is Anthropic. **`ANTHROPIC_COT_BUDGET_MAX`** optionally caps any effective budget (character or env default). **Why:** Agent owners set policy in stored character data; request bodies must not carry budgets (untrusted MCP/A2A callers). Env still supplies defaults where no character field exists and caps worst-case cost. +- **`ANTHROPIC_COT_BUDGET`** (existing) — Clarified role as **default** when the character omits `anthropicThinkingBudgetTokens` (or value is invalid), plus baseline for routes without a resolved character. **Why:** One deploy-level knob for generic chat; per-agent overrides stay in JSON. +- **`parseThinkingBudgetFromCharacterSettings`**, **`resolveAnthropicThinkingBudgetTokens`**, **`parseAnthropicCotBudgetMaxFromEnv`**, **`ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY`** — See `packages/lib/providers/anthropic-thinking.ts`. **Why:** Single resolution path and a stable settings key for dashboards/APIs. +- **`packages/lib/providers/cloud-provider-options.ts`** — Shared type for merged `providerOptions`. **Why:** Type-safe merges without `any`. +- **`mockMiladyPricingMinimumDepositForRouteTests`** — Test helper in `packages/tests/helpers/mock-milady-pricing-for-route-tests.ts`. **Why:** Partial `MILADY_PRICING` mocks broke Milady billing cron under full `bun run test:unit`. + +### Changed + +- **`POST /api/agents/{id}/mcp`** (`chat` tool) and **`POST /api/agents/{id}/a2a`** (`chat`) pass character `settings` into `mergeAnthropicCotProviderOptions`. **Why:** Those routes always resolve a `user_characters` row; other v1 routes remain env-only until a character is available on the request path. +- **Milady billing cron unit tests** — `z-milady-billing-route.test.ts`, queue-backed DB mocks, `package.json` script paths. **Why:** `mock.module` ordering and partial pricing objects caused flaky full-suite failures. + +### Documentation + +- **`docs/anthropic-cot-budget.md`** — Per-agent settings, env default/max, operator checklist, MCP/A2A scope. +- **`docs/unit-testing-milady-mocks.md`** — Milady `mock.module` pitfalls. +- **`docs/ROADMAP.md`** — Done / near-term items. diff --git a/README.md b/README.md index d6439da63..ae6de068e 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,9 @@ cloud/ ├── .env.example # Environment template ├── docs/ # Detailed documentation │ ├── API_REFERENCE.md # Complete API reference +│ ├── anthropic-cot-budget.md # ANTHROPIC_COT_BUDGET + provider merge WHYs +│ ├── unit-testing-milady-mocks.md # Bun mock.module + Milady pricing test WHYs +│ ├── ROADMAP.md # Product direction and done items │ ├── DEPLOYMENT.md # Deployment guide │ ├── DEPLOYMENT_TROUBLESHOOTING.md # Troubleshooting │ ├── STRIPE_SETUP.md # Stripe integration @@ -553,10 +556,17 @@ Tests are split by kind; use the right script for what you want to run: | `bun run test:unit` | `tests/unit/` | Unit tests (mocked deps, fast) | Env preload only; some skip without `DATABASE_URL` | | `bun run test:integration` | `tests/integration/` | API/DB/E2E integration tests | `DATABASE_URL` (+ migrations); some need a running server | | `bun run test:runtime` | `tests/runtime/` | Runtime/factory and perf tests | `DATABASE_URL` (+ migrations), heavier | -| `bun run test` | all of the above | Full suite in one run | Same as integration + runtime for those layers | +| `bun run test` | `test:repo-unit:bulk` + `special` | Two staged **unit** batches (see `package.json` for included/excluded files) | Env preload only (same family as `test:unit`) | | `bun run test:playwright` | `tests/playwright/` | Playwright E2E (optional) | `@playwright/test` installed | -Env is loaded from `.env`, `.env.local`, and `.env.test` via preload. See `docs/test-failure-assessment.md` for skip behavior and remaining failure categories. +Env is loaded from `.env`, `.env.local`, and `.env.test` via preload. + +### Engineering docs (WHYs) + +- **[docs/unit-testing-milady-mocks.md](docs/unit-testing-milady-mocks.md)** — Why partial `MILADY_PRICING` mocks break other Milady modules under Bun, and how the billing cron tests isolate `mock.module("@/db/client")` contention. +- **[docs/anthropic-cot-budget.md](docs/anthropic-cot-budget.md)** — Per-agent `settings.anthropicThinkingBudgetTokens` (MCP/A2A), env default (`ANTHROPIC_COT_BUDGET`) and cap (`ANTHROPIC_COT_BUDGET_MAX`), and **why** thinking budgets are not request parameters. +- **[CHANGELOG.md](CHANGELOG.md)** — Engineering changelog (Keep a Changelog style). +- **[docs/ROADMAP.md](docs/ROADMAP.md)** — Product direction and rationale; “Done” links to the above where relevant. ### Development Workflow @@ -711,6 +721,8 @@ const { messages, input, handleSubmit, isLoading } = useChat({ **Anthropic Messages API (Claude Code):** For tools that expect the [Anthropic Messages API](https://docs.anthropic.com/en/api/messages) (e.g. Claude Code), use **POST /api/v1/messages** with the same request/response shape. Set `ANTHROPIC_BASE_URL=https://cloud.milady.ai/api/v1` and `ANTHROPIC_API_KEY` to your Cloud API key so usage goes through Cloud credits instead of a direct Anthropic key. See [API docs → Anthropic Messages](/docs/api/messages). *Why: single API key and billing for both OpenAI-style and Anthropic-style clients.* +**Public cloud agents (MCP / A2A) — Anthropic extended thinking:** For **`POST /api/agents/{id}/mcp`** (`chat` tool) and **`POST /api/agents/{id}/a2a`** (`chat`), extended thinking uses the character’s **`settings.anthropicThinkingBudgetTokens`** when the model is Anthropic (`0` = off; omitted = fall back to `ANTHROPIC_COT_BUDGET`). Optional **`ANTHROPIC_COT_BUDGET_MAX`** clamps any effective budget. *Why: the agent owner controls cost/quality per agent; MCP/A2A clients cannot pass a thinking budget in the request (untrusted input).* See [docs/anthropic-cot-budget.md](docs/anthropic-cot-budget.md). + ### 2. AI Image Generation **Location**: `/dashboard/image` and `/app/api/v1/generate-image/route.ts` diff --git a/anthropic-thinking.test.ts b/anthropic-thinking.test.ts new file mode 100644 index 000000000..ef7ee6dab --- /dev/null +++ b/anthropic-thinking.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import { + validateBudgetTokens, + getThinkingConfig, + buildThinkingParam, + supportsExtendedThinking, + type ThinkingConfig, + type CharacterThinkingSettings, +// Note: imports are structured to unify testing across different modules consistently +} from "./anthropic-thinking"; + +describe("anthropic-thinking", () => { + describe("validateBudgetTokens", () => { + it("returns default budget when undefined", () => { + expect(validateBudgetTokens(undefined)).toBe(10000); + }); + + it("clamps to minimum budget", () => { + expect(validateBudgetTokens(500)).toBe(1000); + expect(validateBudgetTokens(0)).toBe(1000); + expect(validateBudgetTokens(-100)).toBe(1000); + }); + + it("clamps to maximum budget", () => { + expect(validateBudgetTokens(150000)).toBe(100000); + expect(validateBudgetTokens(100001)).toBe(100000); + }); + + it("returns valid values within range", () => { + expect(validateBudgetTokens(1000)).toBe(1000); + expect(validateBudgetTokens(50000)).toBe(50000); + expect(validateBudgetTokens(100000)).toBe(100000); + }); + }); + + describe("getThinkingConfig", () => { + it("returns disabled config when settings undefined", () => { + expect(getThinkingConfig(undefined)).toEqual({ enabled: false }); + }); + + it("returns disabled config when anthropicThinking undefined", () => { + expect(getThinkingConfig({})).toEqual({ enabled: false }); + }); + + it("returns disabled config when enabled is false", () => { + const settings: CharacterThinkingSettings = { + anthropicThinking: { enabled: false }, + }; + expect(getThinkingConfig(settings)).toEqual({ enabled: false }); + }); + + it("returns enabled config with default budget", () => { + const settings: CharacterThinkingSettings = { + anthropicThinking: { enabled: true }, + }; + expect(getThinkingConfig(settings)).toEqual({ + enabled: true, + budgetTokens: 10000, + }); + }); + + it("returns enabled config with custom budget", () => { + const settings: CharacterThinkingSettings = { + anthropicThinking: { enabled: true, budgetTokens: 25000 }, + }; + expect(getThinkingConfig(settings)).toEqual({ + enabled: true, + budgetTokens: 25000, + }); + }); + + it("validates and clamps budget tokens", () => { + const settings: CharacterThinkingSettings = { + anthropicThinking: { enabled: true, budgetTokens: 500 }, + }; + expect(getThinkingConfig(settings)).toEqual({ + enabled: true, + budgetTokens: 1000, + }); + }); + }); + + describe("buildThinkingParam", () => { + it("returns undefined when disabled", () => { + const config: ThinkingConfig = { enabled: false }; + expect(buildThinkingParam(config)).toBeUndefined(); + }); + + it("returns thinking param when enabled with budget", () => { + const config: ThinkingConfig = { enabled: true, budgetTokens: 15000 }; + expect(buildThinkingParam(config)).toEqual({ + type: "enabled", + budget_tokens: 15000, + }); + }); + + it("uses default budget when budgetTokens undefined", () => { + const config: ThinkingConfig = { enabled: true }; + expect(buildThinkingParam(config)).toEqual({ + type: "enabled", + budget_tokens: 10000, + }); + }); + }); + + describe("supportsExtendedThinking", () => { + it("returns true for claude-3-5-sonnet models", () => { + expect(supportsExtendedThinking("claude-3-5-sonnet-20241022")).toBe(true); + expect(supportsExtendedThinking("claude-3-5-sonnet")).toBe(true); + expect(supportsExtendedThinking("Claude-3-5-Sonnet")).toBe(true); + }); + + it("returns true for claude-3.5-sonnet models", () => { + expect(supportsExtendedThinking("claude-3.5-sonnet")).toBe(true); + }); + + it("returns true for claude-3-opus models", () => { + expect(supportsExtendedThinking("claude-3-opus-20240229")).toBe(true); + expect(supportsExtendedThinking("claude-3-opus")).toBe(true); + expect(supportsExtendedThinking("Claude-3-Opus")).toBe(true); + }); + + it("returns false for unsupported models", () => { + expect(supportsExtendedThinking("claude-3-haiku")).toBe(false); + expect(supportsExtendedThinking("claude-2")).toBe(false); + expect(supportsExtendedThinking("gpt-4")).toBe(false); + expect(supportsExtendedThinking("gemini-pro")).toBe(false); + }); + }); +}); diff --git a/app/api/agents/[id]/a2a/route.ts b/app/api/agents/[id]/a2a/route.ts index 77abaf085..cc7fb1e96 100644 --- a/app/api/agents/[id]/a2a/route.ts +++ b/app/api/agents/[id]/a2a/route.ts @@ -11,6 +11,10 @@ * - API key authentication (uses org credits) * * When monetization is enabled, the agent creator earns their markup percentage. + * + * **Anthropic extended thinking:** JSON-RPC `chat` merges thinking from + * `user_characters.settings.anthropicThinkingBudgetTokens`. **Why:** Budget lives on the character + * record, not in caller-supplied params (A2A peers are not trusted to set token limits). */ import { gateway } from "@ai-sdk/gateway"; @@ -20,6 +24,11 @@ import { z } from "zod"; import type { UserCharacter } from "@/db/schemas/user-characters"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; import { calculateCost, estimateRequestCost, getProviderFromModel } from "@/lib/pricing"; +import { + mergeAnthropicCotProviderOptions, + parseThinkingBudgetFromCharacterSettings, + resolveAnthropicThinkingBudgetTokens, +} from "@/lib/providers/anthropic-thinking"; import { agentMonetizationService } from "@/lib/services/agent-monetization"; import { charactersService } from "@/lib/services/characters/characters"; import type { CreditReservation } from "@/lib/services/credits"; @@ -254,6 +263,7 @@ async function handleChat( inference_markup_percentage: string | null; system: string | null; bio: string | string[]; + settings: Record; }, params: Record, rpcId: string | number, @@ -284,9 +294,19 @@ async function handleChat( })), ]; - // Calculate estimated costs + // Calculate estimated costs, including potential thinking budget + // Use resolveAnthropicThinkingBudgetTokens to get effective budget (same as MCP route) + // Add thinking budget on top of base output tokens for accurate credit reservation const provider = getProviderFromModel(model); - const baseCost = await estimateRequestCost(model, fullMessages); + const agentThinkingBudget = parseThinkingBudgetFromCharacterSettings(character.settings); + const effectiveThinkingBudget = resolveAnthropicThinkingBudgetTokens( + model, + process.env, + agentThinkingBudget ?? undefined, + ); + // Add thinking budget to base output estimate (500 tokens) to match MCP route behavior + const maxOutputTokens = effectiveThinkingBudget != null ? 500 + effectiveThinkingBudget : undefined; + const baseCost = await estimateRequestCost(model, fullMessages, maxOutputTokens); // Apply markup if monetization is enabled const markupPct = Number(character.inference_markup_percentage || 0); @@ -321,6 +341,11 @@ async function handleChat( const result = await streamText({ model: gateway.languageModel(model), messages: fullMessages, + ...mergeAnthropicCotProviderOptions( + model, + process.env, + agentThinkingBudget, + ), }); let fullText = ""; diff --git a/app/api/agents/[id]/mcp/route.ts b/app/api/agents/[id]/mcp/route.ts index 0aeb33bb3..5f5b45316 100644 --- a/app/api/agents/[id]/mcp/route.ts +++ b/app/api/agents/[id]/mcp/route.ts @@ -11,6 +11,10 @@ * - API key authentication (uses org credits) * * When monetization is enabled, the agent creator earns their markup percentage. + * + * **Anthropic extended thinking:** The `chat` tool merges `providerOptions` using + * `user_characters.settings.anthropicThinkingBudgetTokens` (see `parseThinkingBudgetFromCharacterSettings`). + * **Why:** Thinking budget is owner-defined on the character, not passed by MCP clients (untrusted). */ import { gateway } from "@ai-sdk/gateway"; @@ -19,6 +23,11 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; import { calculateCost, estimateTokens, getProviderFromModel } from "@/lib/pricing"; +import { + mergeAnthropicCotProviderOptions, + parseThinkingBudgetFromCharacterSettings, + resolveAnthropicThinkingBudgetTokens, +} from "@/lib/providers/anthropic-thinking"; import { agentMonetizationService } from "@/lib/services/agent-monetization"; import { charactersService } from "@/lib/services/characters/characters"; import type { CreditReservation } from "@/lib/services/credits"; @@ -263,6 +272,7 @@ async function handleToolCall( inference_markup_percentage: string | null; system: string | null; bio: string | string[]; + settings: Record; }, params: Record, rpcId: string | number, @@ -320,6 +330,16 @@ async function handleToolCall( const provider = getProviderFromModel(model); const markupPct = Number(character.inference_markup_percentage || 0); + // Resolve effective thinking budget before reservation (applies ANTHROPIC_COT_BUDGET_MAX cap) + const agentThinkingBudget = parseThinkingBudgetFromCharacterSettings(character.settings); + const effectiveThinkingBudget = + resolveAnthropicThinkingBudgetTokens(model, process.env, agentThinkingBudget) ?? 0; + // Include thinking budget in output token estimate for Anthropic models + const baseOutputTokens = 500; + const estimatedOutputTokens = model.includes("claude") && effectiveThinkingBudget > 0 + ? baseOutputTokens + effectiveThinkingBudget + : baseOutputTokens; + // Reserve credits BEFORE LLM call to prevent TOCTOU race condition let reservation: CreditReservation; try { @@ -328,7 +348,7 @@ async function handleToolCall( model, provider, estimatedInputTokens: estimateTokens(systemPrompt + message), - estimatedOutputTokens: 500, + estimatedOutputTokens, userId: authResult.user.id, description: `Agent MCP: ${character.name}`, }); @@ -350,6 +370,11 @@ async function handleToolCall( const result = await streamText({ model: gateway.languageModel(model), messages, + ...mergeAnthropicCotProviderOptions( + model, + process.env, + agentThinkingBudget, + ), }); let fullText = ""; diff --git a/app/api/mcp/tools/generation.ts b/app/api/mcp/tools/generation.ts index 59384e2a4..d6cb6e04d 100644 --- a/app/api/mcp/tools/generation.ts +++ b/app/api/mcp/tools/generation.ts @@ -7,6 +7,10 @@ import { gateway } from "@ai-sdk/gateway"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { streamText } from "ai"; +import { + mergeAnthropicCotProviderOptions, + mergeGoogleImageModalitiesWithAnthropicCot, +} from "@/lib/providers/anthropic-thinking"; import { z } from "zod/v3"; import { uploadBase64Image } from "@/lib/blob"; import { calculateCost, getProviderFromModel, IMAGE_GENERATION_COST } from "@/lib/pricing"; @@ -134,9 +138,14 @@ export function registerGenerationTools(server: McpServer): void { generationId = generation.id; // Generate text (non-streaming for MCP) + // MCP text generation intentionally inherits ANTHROPIC_COT_BUDGET if set in env. + // Unlike SEO/promotion routes (which pass 0 to disable for temperature compat), + // interactive text-gen benefits from extended thinking. No explicit temperature + // is set here, so CoT's temperature override is acceptable. const result = await streamText({ model: gateway.languageModel(model), prompt, + ...mergeAnthropicCotProviderOptions(model), }); let fullText = ""; @@ -294,11 +303,10 @@ export function registerGenerationTools(server: McpServer): void { const enhancedPrompt = `${prompt}, ${aspectRatioDescriptions[aspectRatio]}`; + const geminiImageModel = "google/gemini-2.5-flash-image"; const result = streamText({ - model: "google/gemini-2.5-flash-image", - providerOptions: { - google: { responseModalities: ["TEXT", "IMAGE"] }, - }, + model: geminiImageModel, + ...mergeGoogleImageModalitiesWithAnthropicCot(geminiImageModel), prompt: `Generate an image: ${enhancedPrompt}`, }); diff --git a/app/api/v1/admin/service-pricing/__tests__/route.integration.test.ts b/app/api/v1/admin/service-pricing/__tests__/route.integration.test.ts index 8ac9e474b..6c5c0f72b 100644 --- a/app/api/v1/admin/service-pricing/__tests__/route.integration.test.ts +++ b/app/api/v1/admin/service-pricing/__tests__/route.integration.test.ts @@ -45,12 +45,15 @@ const mockUpsert = vi.mocked(servicePricingRepository.upsert); const mockInvalidateCache = vi.mocked(invalidateServicePricingCache); function createRequest(method: string, url: string, body?: unknown): NextRequest { - const init: RequestInit = { method }; + const u = new URL(url, "http://localhost"); if (body) { - init.body = JSON.stringify(body); - init.headers = { "Content-Type": "application/json" }; + return new NextRequest(u, { + method, + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); } - return new NextRequest(new URL(url, "http://localhost"), init); + return new NextRequest(u, { method }); } describe("Service Pricing Admin API - Integration", () => { diff --git a/app/api/v1/chat/completions/route.ts b/app/api/v1/chat/completions/route.ts index 4bb7d209c..e666a142d 100644 --- a/app/api/v1/chat/completions/route.ts +++ b/app/api/v1/chat/completions/route.ts @@ -21,6 +21,7 @@ import { normalizeModelName, } from "@/lib/pricing"; import { getLanguageModel } from "@/lib/providers/language-model"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { billUsage, estimateInputTokens, @@ -430,6 +431,7 @@ async function handleStreamingRequest( : undefined, }); + // Anthropic extended thinking: ANTHROPIC_COT_BUDGET (>0); @ai-sdk/anthropic strips temp/topP/topK when thinking is on. const result = streamText({ model: getLanguageModel(model), system: systemPrompt, @@ -438,6 +440,7 @@ async function handleStreamingRequest( timeout: timeoutMs, ...safeParams, ...(request.max_tokens && { maxOutputTokens: request.max_tokens }), + ...mergeAnthropicCotProviderOptions(model), onFinish: async ({ text, usage }) => { try { const billing = await billUsage( @@ -606,6 +609,7 @@ async function handleNonStreamingRequest( timeout: timeoutMs, ...safeParamsNonStream, ...(request.max_tokens && { maxOutputTokens: request.max_tokens }), + ...mergeAnthropicCotProviderOptions(model), }); // Bill using actual usage from SDK response diff --git a/app/api/v1/chat/route.ts b/app/api/v1/chat/route.ts index 4064118ee..79a0871a6 100644 --- a/app/api/v1/chat/route.ts +++ b/app/api/v1/chat/route.ts @@ -9,6 +9,7 @@ import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; import { resolveModel } from "@/lib/models"; import { estimateTokens } from "@/lib/pricing"; import { getLanguageModel } from "@/lib/providers/language-model"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { billUsage } from "@/lib/services/ai-billing"; import { anonymousSessionsService } from "@/lib/services/anonymous-sessions"; import { contentModerationService } from "@/lib/services/content-moderation"; @@ -290,6 +291,7 @@ async function handlePOST(req: NextRequest) { messages: await convertToModelMessages(messages), abortSignal: req.signal, timeout: routeTimeoutMs, + ...mergeAnthropicCotProviderOptions(selectedModel), onFinish: async ({ text, usage }) => { try { if (!usage) { diff --git a/app/api/v1/generate-image/route.ts b/app/api/v1/generate-image/route.ts index 10a893759..f59d2212f 100644 --- a/app/api/v1/generate-image/route.ts +++ b/app/api/v1/generate-image/route.ts @@ -3,6 +3,10 @@ import { streamText } from "ai"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { requireAuthOrApiKey } from "@/lib/auth"; +import { + mergeAnthropicCotProviderOptions, + mergeGoogleImageModalitiesWithAnthropicCot, +} from "@/lib/providers/anthropic-thinking"; import { getAnonymousUser, getOrCreateAnonymousUser } from "@/lib/auth-anonymous"; import { uploadBase64Image } from "@/lib/blob"; import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; @@ -334,9 +338,6 @@ async function handlePOST(req: NextRequest) { streamConfig = { model: imageModel, - providerOptions: { - google: { responseModalities: ["TEXT", "IMAGE"] }, - }, messages: [ { role: "user", @@ -358,9 +359,6 @@ async function handlePOST(req: NextRequest) { // Google/other models: Text-to-image with simple prompt streamConfig = { model: imageModel, - providerOptions: { - google: { responseModalities: ["TEXT", "IMAGE"] }, - }, prompt: `Generate an image: ${enhancedPrompt}`, }; } @@ -369,7 +367,16 @@ async function handlePOST(req: NextRequest) { let textResponse = ""; try { - const result = streamText(streamConfig); + // Image generation routes explicitly disable CoT (pass 0) because: + // 1. Extended thinking is not applicable to image generation + // 2. Temperature control must be preserved for image quality + // 3. Future Anthropic image models should not silently receive thinking options + // Google models need responseModalities for image output; all others get empty options. + const isGoogleModel = imageModel.startsWith("google/"); + const cotOpts = isGoogleModel + ? mergeGoogleImageModalitiesWithAnthropicCot(imageModel, process.env, 0) + : mergeAnthropicCotProviderOptions(imageModel, process.env, 0); + const result = streamText({ ...streamConfig, ...cotOpts }); for await (const delta of result.fullStream) { switch (delta.type) { diff --git a/app/api/v1/messages/route.ts b/app/api/v1/messages/route.ts index e814d3770..ebbca6ba4 100644 --- a/app/api/v1/messages/route.ts +++ b/app/api/v1/messages/route.ts @@ -30,6 +30,7 @@ import { getSafeModelParams, normalizeModelName, } from "@/lib/pricing"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { billUsage, estimateInputTokens, @@ -667,6 +668,7 @@ async function handleNonStream( ...safeParams, ...(tools ? { tools } : {}), ...(toolChoice ? { toolChoice } : {}), + ...mergeAnthropicCotProviderOptions(model), }); const billing = await billUsage( @@ -791,6 +793,7 @@ async function handleStream( ...safeParams, ...(tools ? { tools } : {}), ...(toolChoice ? { toolChoice } : {}), + ...mergeAnthropicCotProviderOptions(model), onFinish: async ({ text, totalUsage }) => { try { const billing = await billUsage( diff --git a/app/api/v1/responses/route.ts b/app/api/v1/responses/route.ts index 3b01ebb8e..b2d9bdb99 100644 --- a/app/api/v1/responses/route.ts +++ b/app/api/v1/responses/route.ts @@ -28,6 +28,7 @@ import { normalizeModelName, } from "@/lib/pricing"; import { getProviderForModel } from "@/lib/providers"; +import { mergeGatewayGroqPreferenceWithAnthropicCot } from "@/lib/providers/anthropic-thinking"; import type { OpenAIChatRequest, OpenAIChatResponse } from "@/lib/providers/types"; import { contentModerationService } from "@/lib/services/content-moderation"; import { creditsService } from "@/lib/services/credits"; @@ -661,15 +662,12 @@ async function handlePOST(req: NextRequest) { } const providerInstance = getProviderForModel(model); + // Gateway: Groq preference + optional ANTHROPIC_COT_BUDGET (providerOptions.anthropic.thinking) per AI Gateway docs. const requestWithProvider = isGroqNativeModel(model) ? safeRequest : { ...safeRequest, - providerOptions: { - gateway: { - order: ["groq"], // Use Groq as preferred provider - }, - }, + ...mergeGatewayGroqPreferenceWithAnthropicCot(model), }; const providerResponse = await providerInstance.chatCompletions(requestWithProvider, { signal: req.signal, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7ee9b2c42..dadb18379 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,6 +6,18 @@ High-level direction and rationale. Dates are targets, not commitments. ## Done +### Anthropic extended thinking (per agent + env) (Mar 2026) + +- **What:** `user_characters.settings.anthropicThinkingBudgetTokens` sets thinking per cloud agent (MCP/A2A chat). `ANTHROPIC_COT_BUDGET` is the default when that key is omitted; `ANTHROPIC_COT_BUDGET_MAX` optionally caps any effective budget. +- **Why:** Agent owners control inference policy without redeploying; request bodies must not carry budgets (untrusted clients). Env default + max give operators baseline and cost bounds. +- **Docs:** [docs/anthropic-cot-budget.md](./anthropic-cot-budget.md) + +### Unit tests: Milady `MILADY_PRICING` and billing cron (Mar 2026) + +- **What:** Shared `mockMiladyPricingMinimumDepositForRouteTests()`; Milady billing cron tests use stable DB mocks; `package.json` script paths updated for the renamed test file. +- **Why:** Replacing `@/lib/constants/milady-pricing` with only `{ MINIMUM_DEPOSIT }` stripped hourly rates and warning thresholds for **every later importer in the same Bun process**, so billing cron assertions failed only when the full unit tree ran. Spreading real constants preserves cross-module correctness. +- **Docs:** [docs/unit-testing-milady-mocks.md](./unit-testing-milady-mocks.md) + ### Anthropic Messages API compatibility (Jan 2026) - **What:** POST `/api/v1/messages` with Anthropic request/response format, tools, streaming SSE. @@ -16,6 +28,11 @@ High-level direction and rationale. Dates are targets, not commitments. ## Near term +### Per-agent Anthropic thinking: UX and coverage + +- **Dashboard / character editor** — Expose `settings.anthropicThinkingBudgetTokens` with copy that explains cost vs quality tradeoffs. *Why: today the field is JSON-only; most creators will not discover it from docs alone.* +- **Room- or conversation-scoped chat** — When `/api/v1/chat` (or eliza runtime paths) resolve a `user_characters` row, thread the same `parseThinkingBudgetFromCharacterSettings` + merge helpers. *Why: parity between “chat in app” and “chat via MCP/A2A” for the same agent.* + ### Messages API: extended compatibility - **Streaming tool_use blocks** — Emit `content_block_delta` for tool_use (partial JSON) so clients can stream tool calls. *Why: some SDKs expect incremental tool payloads.* diff --git a/docs/anthropic-cot-budget.md b/docs/anthropic-cot-budget.md new file mode 100644 index 000000000..862e5697b --- /dev/null +++ b/docs/anthropic-cot-budget.md @@ -0,0 +1,63 @@ +# Anthropic extended thinking (per cloud agent + env defaults) + +**Extended thinking** (Anthropic “chain-of-thought” style reasoning) is configured per **cloud agent** (a row in `user_characters`) with optional **deploy** defaults and caps. It is **not** controlled from raw API request bodies. + +## Per-agent setting (`user_characters.settings`) + +| Key | Type | Meaning | +|-----|------|---------| +| `anthropicThinkingBudgetTokens` | Integer ≥ 0 | Token budget for thinking when the model is Anthropic. **`0`** turns thinking **off** for that agent even if env default is set. **Omitted** or invalid → fall back to env (see below). | + +**Why JSON on the character:** The agent’s owner configures inference policy in one place (dashboard / API that updates the character). No redeploy is required to change thinking for a specific public agent. + +**Why not a request parameter:** Callers of MCP/A2A/chat could not be trusted to raise thinking budgets and spend more tokens; the stored character record is the source of truth. + +Exported constant: `ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY` in `packages/lib/providers/anthropic-thinking.ts` (value: `anthropicThinkingBudgetTokens`). + +## Environment variables + +| Variable | Role | +|----------|------| +| `ANTHROPIC_COT_BUDGET` | **Default** budget when the character **does not** set `anthropicThinkingBudgetTokens` (or it is invalid). Unset / empty / `0` → no budget from env (thinking stays off unless the character sets a positive integer). | +| `ANTHROPIC_COT_BUDGET_MAX` | Optional **ceiling** for any effective budget (character value **or** env default): `effective = min(requested, max)`. Unset / empty / `0` → no cap. | + +**Why a default env:** Operators can turn on a baseline for routes that have **no** character context (e.g. generic `/api/v1/chat`) while agents with explicit settings override or disable locally. + +**Why a max env:** Caps worst-case token use if a character sets a very large `anthropicThinkingBudgetTokens`. + +## Where per-agent budget is applied + +Today, **`parseThinkingBudgetFromCharacterSettings`** is wired into: + +- `POST /api/agents/{id}/mcp` (tool `chat`) +- `POST /api/agents/{id}/a2a` (method `chat`) + +Other routes keep **env-only** behavior (no `character.settings` on the request path): + +- `POST /api/v1/chat` — uses `mergeAnthropicCotProviderOptions` with env defaults only (`ANTHROPIC_COT_BUDGET` / `ANTHROPIC_COT_BUDGET_MAX`). No character context is available on this route. + +## Merge helpers (`mergeProviderOptions`, …) + +Routes may already set `gateway` or `google` under `providerOptions`. Helpers **deep-merge** known top-level keys so adding `anthropic.thinking` does not drop sibling options. + +## `cloud-provider-options.ts` + +`CloudMergedProviderOptions` matches AI SDK `Record` so merged objects stay type-safe without `any`. + +## How to set `anthropicThinkingBudgetTokens` + +Update the character’s `settings` JSON (`user_characters.settings` in PostgreSQL)—via your **character edit API**, **dashboard** (when exposed), or a one-off SQL/admin tool. The value must be a **finite number**; non-numbers are ignored and env default applies. + +**Why there is no query/body parameter on MCP/A2A:** Consumers are often third-party tools; letting them pass a thinking budget would let anyone raise token spend against the billed org. The **character record** is authenticated-owner data. + +## Operator checklist + +1. Set **`ANTHROPIC_COT_BUDGET_MAX`** in production if you want a hard ceiling on thinking tokens. +2. Optionally set **`ANTHROPIC_COT_BUDGET`** as a default for routes **without** a resolved character (e.g. `/api/v1/chat`). +3. Document for creators: add `anthropicThinkingBudgetTokens` under **Settings** when you ship UI, or point them at this doc for API-managed characters. + +## Related code + +- `packages/lib/providers/anthropic-thinking.ts` — resolution, merges, character parser +- `packages/lib/config/env-validator.ts` — validates env keys when set +- `packages/tests/unit/anthropic-thinking.test.ts` — unit tests diff --git a/docs/unit-testing-milady-mocks.md b/docs/unit-testing-milady-mocks.md new file mode 100644 index 000000000..9fbb2864b --- /dev/null +++ b/docs/unit-testing-milady-mocks.md @@ -0,0 +1,58 @@ +# Unit testing Milady routes and `mock.module` pitfalls + +This document explains **why** several Milady-related unit tests are structured the way they are, and how to avoid regressions that only show up when the **full** unit suite runs (not when a single file runs in isolation). + +## Why partial `MILADY_PRICING` mocks broke the billing cron tests + +`@/lib/constants/milady-pricing` exports a single object, `MILADY_PRICING`, with: + +- Hourly rates (`RUNNING_HOURLY_RATE`, `IDLE_HOURLY_RATE`) +- Thresholds (`MINIMUM_DEPOSIT`, `LOW_CREDIT_WARNING`) +- Operational constants (`GRACE_PERIOD_HOURS`) +- Derived getters for display + +Some route tests only care about **`MINIMUM_DEPOSIT`** (e.g. provisioning or create-agent flows). It is tempting to write: + +```ts +mock.module("@/lib/constants/milady-pricing", () => ({ + MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, +})); +``` + +**Why this fails:** In Bun, `mock.module` replaces the **entire** module for the process. Any **later** importer—including `app/api/cron/milady-billing/route.ts`—sees **only** `{ MINIMUM_DEPOSIT }`. Fields such as `RUNNING_HOURLY_RATE` and `LOW_CREDIT_WARNING` become `undefined`. The cron handler still returns HTTP 200 for many paths, but billing math and warning thresholds are wrong, so assertions on `sandboxesBilled`, `warningsSent`, etc. fail **after** another test file has loaded. + +**Why the failure is order-dependent:** If you run only `z-milady-billing-route.test.ts`, no other file has replaced `milady-pricing` with a partial mock, so tests pass. Running `packages/tests/unit` loads many files; whichever partial mock is registered last “wins” until something else overrides it—so symptoms depend on discovery order. + +**What we do instead:** Use `mockMiladyPricingMinimumDepositForRouteTests()` from `packages/tests/helpers/mock-milady-pricing-for-route-tests.ts`, which spreads the **real** `MILADY_PRICING` and overrides only `MINIMUM_DEPOSIT`. **Why:** One source of truth for numeric constants; tests stay focused on deposit behavior without stripping fields other modules need. + +## Why the Milady billing cron test file is named `z-milady-billing-route.test.ts` + +Repo scripts `test:repo-unit:bulk` and `test:repo-unit:special` split the unit corpus: bulk runs almost all files, special runs a short list. The billing cron test is intentionally listed in **special** and excluded from bulk so it can run in a predictable batch with other heavy or order-sensitive tests. + +The **`z-` prefix** is only a mnemonic for “keep this file easy to spot / last in sorted lists” when curating `find` exclusions. **Why:** The important part is `package.json` explicitly listing the path, not the letter `z` itself. + +## Why `registerMiladyBillingMocks()` uses inline functions (not `mock()` for `dbRead` / `dbWrite`) + +Several API route tests call `mock.module("@/db/client", …)` with their own `dbRead` / `dbWrite` shapes. Re-registering mocks in `beforeEach` is meant to restore the cron test’s queues. + +**Why `mock()` was fragile:** Bun’s mock instances can end up **decoupled** from what newly imported route modules call after another file’s `mock.module` runs, depending on order and cache behavior. Supplying **plain functions** that close over shared `readResultsQueue` / `txUpdateResultsQueue` arrays keeps each `mock.module("@/db/client", …)` registration wired to the same queue-backed behavior. + +## Why `registerMiladyBillingMocks()` runs in `beforeEach` + +**Why:** Any prior test file may have replaced `@/db/client`, `@/db/repositories`, or logger modules. Running registration before each test maximizes the chance the cron route under test sees **this** file’s doubles, without requiring every other test file to restore global mocks in `afterEach`. + +## Related files + +| File | Role | +|------|------| +| `packages/tests/helpers/mock-milady-pricing-for-route-tests.ts` | Safe `MILADY_PRICING` mock helper | +| `packages/tests/unit/z-milady-billing-route.test.ts` | Milady billing cron handler tests | +| `app/api/cron/milady-billing/route.ts` | Production cron (imports full `MILADY_PRICING`) | +| `packages/lib/constants/milady-pricing.ts` | Canonical pricing object | + +## Commands + +```bash +bun run test:unit # Full unit tree (includes Milady billing test) +bun run test # Bulk + special scripts from package.json +``` diff --git a/package.json b/package.json index ba0c5819b..3c0d700bb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,10 @@ "check-types": "bun run packages/scripts/check-types-split.ts", "check-types:full": "tsc --noEmit", "check-types:tests": "tsc --noEmit --project tsconfig.test.json", + "check-types:ui": "bun run --cwd packages/ui typecheck", + "check-types:agent-server": "bun run --cwd services/agent-server typecheck", + "check-types:gateway-discord": "bun run --cwd packages/services/gateway-discord typecheck", + "check-types:gateway-webhook": "bun run --cwd packages/services/gateway-webhook typecheck", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", @@ -51,8 +55,8 @@ "admin:list": "bun run packages/scripts/promote-admin.ts --list", "admin:revoke": "bun run packages/scripts/promote-admin.ts --revoke", "test:unit": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit", - "test:repo-unit:bulk": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts $(find packages/tests/unit -name '*.test.ts' ! -path 'packages/tests/unit/credits.test.ts' ! -path 'packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts' ! -path 'packages/tests/unit/eliza-app/discord-auth.test.ts' ! -path 'packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts' ! -path 'packages/tests/unit/milady-web-ui.test.ts' ! -path 'packages/tests/unit/mcp-twitter-tools.test.ts' ! -path 'packages/tests/unit/affiliates-service.test.ts' ! -path 'packages/tests/unit/proxy-pricing.test.ts' ! -path 'packages/tests/unit/milady-billing-route.test.ts' | sort) packages/lib/services/gateway-discord/__tests__ packages/services/gateway-discord/tests/gateway-manager.test.ts packages/services/gateway-discord/tests/leader-election.test.ts packages/services/gateway-discord/tests/logger.test.ts packages/services/gateway-discord/tests/voice-message-handler.test.ts", - "test:repo-unit:special": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit/credits.test.ts packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts packages/tests/unit/eliza-app/discord-auth.test.ts packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts packages/tests/unit/milady-web-ui.test.ts packages/tests/unit/mcp-twitter-tools.test.ts packages/tests/unit/affiliates-service.test.ts packages/tests/unit/proxy-pricing.test.ts packages/tests/unit/milady-billing-route.test.ts", + "test:repo-unit:bulk": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts $(find packages/tests/unit -name '*.test.ts' ! -path 'packages/tests/unit/credits.test.ts' ! -path 'packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts' ! -path 'packages/tests/unit/eliza-app/discord-auth.test.ts' ! -path 'packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts' ! -path 'packages/tests/unit/milady-web-ui.test.ts' ! -path 'packages/tests/unit/mcp-twitter-tools.test.ts' ! -path 'packages/tests/unit/affiliates-service.test.ts' ! -path 'packages/tests/unit/proxy-pricing.test.ts' ! -path 'packages/tests/unit/z-milady-billing-route.test.ts' | sort) packages/lib/services/gateway-discord/__tests__ packages/services/gateway-discord/tests/gateway-manager.test.ts packages/services/gateway-discord/tests/leader-election.test.ts packages/services/gateway-discord/tests/logger.test.ts packages/services/gateway-discord/tests/voice-message-handler.test.ts", + "test:repo-unit:special": "SKIP_DB_DEPENDENT=1 SKIP_SERVER_CHECK=true bun test --preload ./packages/tests/load-env.ts packages/tests/unit/credits.test.ts packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts packages/tests/unit/eliza-app/discord-auth.test.ts packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts packages/tests/unit/milady-web-ui.test.ts packages/tests/unit/mcp-twitter-tools.test.ts packages/tests/unit/affiliates-service.test.ts packages/tests/unit/proxy-pricing.test.ts packages/tests/unit/z-milady-billing-route.test.ts", "test:integration": "bun test --max-concurrency=1 --preload ./packages/tests/e2e/preload.ts packages/tests/integration --timeout 120000", "test:services": "bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts packages/tests/integration/services --timeout 120000", "test:properties": "bun test --max-concurrency=1 --preload ./packages/tests/load-env.ts packages/tests/properties --timeout 300000", diff --git a/packages/content/changelog.mdx b/packages/content/changelog.mdx index 3000f792e..4802b1dcf 100644 --- a/packages/content/changelog.mdx +++ b/packages/content/changelog.mdx @@ -13,6 +13,29 @@ Stay up to date with the latest changes to elizaOS Cloud. ## March 2026 +### Mar 28, 2026 + +**Per-agent Anthropic extended thinking (MCP / A2A)** + +- **`user_characters.settings.anthropicThinkingBudgetTokens`**: integer token budget for Anthropic “extended thinking” on **`POST /api/agents/{id}/mcp`** (`chat`) and **`POST /api/agents/{id}/a2a`** (`chat`). **`0`** disables thinking for that agent even if `ANTHROPIC_COT_BUDGET` is set; omitted or invalid values fall back to env default. **Why:** Agent owners set inference policy in stored character data without redeploying; request bodies must not carry budgets so third-party MCP/A2A callers cannot inflate token spend. +- **`ANTHROPIC_COT_BUDGET`**: clarified as **default** when the character does not set the key (and for routes that have no resolved character). **`ANTHROPIC_COT_BUDGET_MAX`**: optional ceiling on any effective budget. **Why:** Operators keep a baseline and a worst-case cap while still allowing per-agent overrides. + +Docs: [Anthropic CoT / thinking](../../docs/anthropic-cot-budget.md), [CHANGELOG.md](../../CHANGELOG.md). + +### Mar 27, 2026 + +**Anthropic extended thinking (deploy policy)** + +- Optional **`ANTHROPIC_COT_BUDGET`**: positive integer enables Anthropic extended thinking for eligible Claude models via `providerOptions.anthropic.thinking`; unset or `0` leaves behavior unchanged. **Why:** Thinking uses extra tokens—keeping control in environment config avoids arbitrary client-controlled budgets and keeps billing/support predictable. +- Provider-option **merge helpers** preserve existing `gateway` / `google` keys when adding Anthropic fragments. **Why:** Routes already set gateway order or image modalities; overwriting the whole `providerOptions` object would drop those settings. + +**Milady unit tests and pricing mocks** + +- **`mockMiladyPricingMinimumDepositForRouteTests`**: route tests that tweak minimum deposit now spread the real `MILADY_PRICING` object instead of replacing it with `{ MINIMUM_DEPOSIT }` only. **Why:** `mock.module` replaces the entire module for the process; partial objects removed hourly rates and warning thresholds and broke Milady billing cron tests when the full unit suite ran. +- Billing cron test file renamed to `z-milady-billing-route.test.ts` with stable queue-backed DB mocks re-registered in `beforeEach`. **Why:** Reduces order-dependent failures when other tests also mock `@/db/client`. + +Engineering details: [CHANGELOG.md](../../CHANGELOG.md), [docs/unit-testing-milady-mocks.md](../../docs/unit-testing-milady-mocks.md), [docs/anthropic-cot-budget.md](../../docs/anthropic-cot-budget.md). + ### Mar 8, 2026 **Anthropic Messages API compatibility** diff --git a/packages/lib/api/a2a/skills.ts b/packages/lib/api/a2a/skills.ts index 33f684617..2f122825f 100644 --- a/packages/lib/api/a2a/skills.ts +++ b/packages/lib/api/a2a/skills.ts @@ -3,10 +3,19 @@ * * Core skill implementations for A2A protocol. * Only includes skills that are fully tested and working. + * + * Note: CoT budget uses env-only resolution (no per-character settings) because + * A2A skills operate at the protocol level without a resolved character context. + * The calling agent's character is not available here — skills are invoked via + * the A2A protocol which only provides user/org context, not agent personality. */ import { gateway } from "@ai-sdk/gateway"; import { streamText } from "ai"; +import { + mergeAnthropicCotProviderOptions, + mergeGoogleImageModalitiesWithAnthropicCot, +} from "@/lib/providers/anthropic-thinking"; import { calculateCost, estimateRequestCost, @@ -88,6 +97,7 @@ export async function executeSkillChatCompletion( content: m.content, })), ...options, + ...mergeAnthropicCotProviderOptions(model), }); let fullText = ""; @@ -186,9 +196,10 @@ export async function executeSkillImageGeneration( "3:4": "portrait", }; + const imageModelId = "google/gemini-2.5-flash-image"; const result = streamText({ - model: "google/gemini-2.5-flash-image", - providerOptions: { google: { responseModalities: ["TEXT", "IMAGE"] } }, + model: imageModelId, + ...mergeGoogleImageModalitiesWithAnthropicCot(imageModelId), prompt: `Generate an image: ${prompt}, ${aspectDesc[aspectRatio] || "square"} composition`, }); diff --git a/packages/lib/auth/jwks.ts b/packages/lib/auth/jwks.ts index 1fd7dae75..18085c45e 100644 --- a/packages/lib/auth/jwks.ts +++ b/packages/lib/auth/jwks.ts @@ -5,7 +5,7 @@ * Supports key rotation by allowing multiple active keys identified by "kid". */ -import { exportJWK, importPKCS8, importSPKI, type KeyLike as JoseCryptoKey, type JWK } from "jose"; +import { exportJWK, importPKCS8, importSPKI, type JWK, type KeyLike } from "jose"; /** * Environment variables for JWT signing keys. @@ -21,8 +21,8 @@ const JWT_SIGNING_KEY_ID = process.env.JWT_SIGNING_KEY_ID ?? "primary"; const ALGORITHM = "ES256"; // Cached key instances to avoid repeated parsing -let cachedPrivateKey: JoseCryptoKey | null = null; -let cachedPublicKey: JoseCryptoKey | null = null; +let cachedPrivateKey: KeyLike | null = null; +let cachedPublicKey: KeyLike | null = null; // Log configuration issues once at startup if (!JWT_SIGNING_PRIVATE_KEY && process.env.NODE_ENV !== "test") { @@ -50,7 +50,7 @@ function decodePemKey(base64Key: string, type: "PRIVATE" | "PUBLIC"): string { * Get the private key for signing JWTs. * Keys are cached after first load. */ -export async function getPrivateKey(): Promise { +export async function getPrivateKey(): Promise { if (cachedPrivateKey) { return cachedPrivateKey; } @@ -68,7 +68,7 @@ export async function getPrivateKey(): Promise { * Get the public key for verifying JWTs. * Keys are cached after first load. */ -export async function getPublicKey(): Promise { +export async function getPublicKey(): Promise { if (cachedPublicKey) { return cachedPublicKey; } diff --git a/packages/lib/config/env-validator.ts b/packages/lib/config/env-validator.ts index 903503636..c455e6a0d 100644 --- a/packages/lib/config/env-validator.ts +++ b/packages/lib/config/env-validator.ts @@ -75,6 +75,70 @@ const ENV_VARS = { description: "AI Gateway API key", }, + ANTHROPIC_COT_BUDGET: { + required: false, + // Note: 0 is treated as "disabled" by parseAnthropicCotBudgetFromEnv (returns null). + // Only positive integers enable thinking. Invalid non-empty values throw at runtime, + // so we fail fast at startup with failOnInvalid: true. + failOnInvalid: true, + description: + "Default Anthropic extended-thinking token budget when a character omits settings.anthropicThinkingBudgetTokens. Positive integer to enable; 0 or unset = disabled", + validate: (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return false; + } + if (!/^\d+$/.test(trimmed)) { + return false; + } + const n = Number.parseInt(trimmed, 10); + // 0 is valid (means disabled), positive integers enable thinking + return n >= 0 && n <= Number.MAX_SAFE_INTEGER; + }, + errorMessage: + "Must be a non-negative integer; 0 or unset = disabled, positive = thinking budget tokens", + }, + + ANTHROPIC_COT_BUDGET_MAX: { + required: false, + // Note: Invalid non-empty values here trigger request-time exceptions in anthropic-thinking.ts. + // Validation failures for this variable should be treated as errors (not warnings) to fail fast. + failOnInvalid: true, + description: + "Optional ceiling (tokens) for any effective Anthropic thinking budget (character setting or env default). Unset = no cap", + validate: (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return false; + } + if (!/^\d+$/.test(trimmed)) { + return false; + } + const n = Number.parseInt(trimmed, 10); + return n >= 0 && n <= Number.MAX_SAFE_INTEGER; + }, + errorMessage: "Must be a non-negative integer string (0 = no cap)", + }, + + RATE_LIMIT_MULTIPLIER: { + required: false, + failOnInvalid: true, + description: + "Multiplier for rate limit thresholds (e.g., 100 for 100x limits in dev). Defaults to 1 (production limits).", + validate: (value: string) => { + const trimmed = value.trim(); + if (trimmed === "") { + return false; + } + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + return false; + } + const n = Number.parseFloat(trimmed); + return n > 0 && Number.isFinite(n); + }, + errorMessage: "Must be a positive number (e.g., 1, 10, 100)", + }, + // Storage BLOB_READ_WRITE_TOKEN: { required: false, @@ -191,11 +255,13 @@ export function validateEnvironment(): EnvValidationResult { if ("validate" in config && config.validate && !config.validate(value)) { const errorMsg = "errorMessage" in config && config.errorMessage ? config.errorMessage : "Invalid format"; - if (config.required) { + // Treat as error if required OR if failOnInvalid is set (for optional vars that throw at runtime) + const treatAsError = config.required || ("failOnInvalid" in config && config.failOnInvalid); + if (treatAsError) { errors.push({ variable, message: `${variable}: ${errorMsg}`, - required: true, + required: config.required, }); } else { warnings.push({ diff --git a/packages/lib/eliza/runtime-factory.ts b/packages/lib/eliza/runtime-factory.ts index fc09bfcd4..102a0e64f 100644 --- a/packages/lib/eliza/runtime-factory.ts +++ b/packages/lib/eliza/runtime-factory.ts @@ -13,13 +13,12 @@ import { type UUID, type World, } from "@elizaos/core"; -import { createDatabaseAdapter } from "@elizaos/plugin-sql/node"; +import createDatabaseAdapterDefault from "@elizaos/plugin-sql/node"; + import { DEFAULT_IMAGE_MODEL } from "@/lib/models"; import { logger } from "@/lib/utils/logger"; import { agentLoader } from "./agent-loader"; import { buildElevenLabsSettings, getDefaultModels, getElizaCloudApiUrl } from "./config"; -import mcpPlugin from "./plugin-mcp"; -import type { UserContext } from "./user-context"; import "@/lib/polyfills/dom-polyfills"; import { edgeRuntimeCache, @@ -27,6 +26,26 @@ import { KNOWN_EMBEDDING_DIMENSIONS, } from "@/lib/cache/edge-runtime-cache"; +// Note: @elizaos/plugin-sql/node exports a CommonJS default that TypeScript cannot +// infer through the ESM boundary. We validate and assert the known signature here. +// If the upstream adapter factory signature changes, this will throw at startup. +// TODO: Check if @elizaos/plugin-sql exports a proper named type for the adapter factory. +type CreateDatabaseAdapterFn = ( + config: { postgresUrl: string }, + agentId: UUID, +) => IDatabaseAdapter; + +function ensureCreateDatabaseAdapter(fn: unknown): CreateDatabaseAdapterFn { + if (typeof fn !== "function") { + throw new TypeError( + 'Default export from "@elizaos/plugin-sql/node" is not a callable database adapter factory', + ); + } + return fn as CreateDatabaseAdapterFn; +} + +const createDatabaseAdapter = ensureCreateDatabaseAdapter(createDatabaseAdapterDefault); + const adapterEmbeddingDimensions = new Map(); /** diff --git a/packages/lib/middleware/rate-limit.ts b/packages/lib/middleware/rate-limit.ts index 32bd48939..09900c1ee 100644 --- a/packages/lib/middleware/rate-limit.ts +++ b/packages/lib/middleware/rate-limit.ts @@ -41,6 +41,11 @@ function validateRateLimitConfig() { if (hasValidatedConfig) return; hasValidatedConfig = true; + // Note: Set RATE_LIMIT_DISABLED=true in development to bypass rate limiting for integration tests. + if (process.env.RATE_LIMIT_DISABLED === "true" && process.env.NODE_ENV !== "production") { + return; + } + if (process.env.NODE_ENV === "production") { if (process.env.REDIS_RATE_LIMITING !== "true") { throw new Error( @@ -51,7 +56,9 @@ function validateRateLimitConfig() { } logger.info("[Rate Limit] ✓ Using Redis-backed rate limiting (production mode)"); } else { - logger.info("[Rate Limit] 🔓 Development mode: Rate limits relaxed (10000 req/window)"); + logger.info( + "[Rate Limit] Development mode: same numeric limits as production; storage is in-memory (set REDIS_RATE_LIMITING=true to use Redis).", + ); } } @@ -283,8 +290,8 @@ export function withRateLimit>( // Add rate limit headers to successful responses // Create new response with additional headers to preserve immutability const newHeaders = new Headers(response.headers); - for (const [key, value] of Object.entries(headers)) { - newHeaders.set(key, value); + for (const [headerKey, value] of Object.entries(headers)) { + newHeaders.set(headerKey, value); } return new Response(response.body, { @@ -296,52 +303,65 @@ export function withRateLimit>( } /** - * Preset rate limit configurations - * DEVELOPMENT: Very high limits to allow rapid testing and iteration - * PRODUCTION: Strict limits to protect against abuse + * Get rate limit multiplier from environment. + * Allows local developers to increase limits without code changes. + * Set RATE_LIMIT_MULTIPLIER=100 in .env.local to effectively disable limits during dev. + * Default is 1 (production-level limits). + */ +function getRateLimitMultiplier(): number { + const multiplier = process.env.RATE_LIMIT_MULTIPLIER; + if (!multiplier) return 1; + const parsed = Number.parseInt(multiplier, 10); + return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed; +} + +/** + * Preset rate limit configurations (same values in dev and production). + * Only the backing store differs: Redis when REDIS_RATE_LIMITING=true, else in-memory. + * + * NOTE FOR LOCAL DEVELOPMENT: These are production-level limits by default. + * Set RATE_LIMIT_MULTIPLIER=100 in .env.local to increase limits for local dev/testing. */ -const isDevelopment = process.env.NODE_ENV !== "production"; +const rateLimitMultiplier = getRateLimitMultiplier(); export const RateLimitPresets = { - // Generous limits for general API usage + /** 60 requests per minute - standard API endpoints */ STANDARD: { - windowMs: 60000, // 1 minute - maxRequests: isDevelopment ? 10000 : 60, // Dev: virtually unlimited, Prod: 60/min + windowMs: 60000, + maxRequests: 60 * rateLimitMultiplier, }, - // Strict limits for expensive operations + /** 10 requests per minute - sensitive operations */ STRICT: { - windowMs: 60000, // 1 minute - maxRequests: isDevelopment ? 10000 : 10, // Dev: virtually unlimited, Prod: 10/min + windowMs: 60000, + maxRequests: 10 * rateLimitMultiplier, }, - // Relaxed limits for high-frequency AI endpoints (chat completions, responses) + /** 200 requests per minute - high-throughput endpoints */ RELAXED: { - windowMs: 60000, // 1 minute - maxRequests: isDevelopment ? 10000 : 200, // Dev: virtually unlimited, Prod: 200/min + windowMs: 60000, + maxRequests: 200 * rateLimitMultiplier, }, - // Very strict for critical operations (deployments, payments) + /** 5 requests per 5 minutes - critical/expensive operations */ CRITICAL: { - windowMs: 300000, // 5 minutes - maxRequests: isDevelopment ? 10000 : 5, // Dev: virtually unlimited, Prod: 5/5min + windowMs: 300000, + maxRequests: 5 * rateLimitMultiplier, }, - // Burst allowance for real-time features + /** 10 requests per second - burst protection */ BURST: { - windowMs: 1000, // 1 second - maxRequests: isDevelopment ? 1000 : 10, // Dev: 1000/sec, Prod: 10/sec + windowMs: 1000, + maxRequests: 10 * rateLimitMultiplier, }, - // Aggressive limits for webhook endpoints (external services calling us) - // Webhooks are server-to-server and should be rate limited per IP - // 100/min is reasonable for payment provider callbacks + /** 100 requests per minute, keyed by IP - for public endpoints */ AGGRESSIVE: { - windowMs: 60000, // 1 minute - maxRequests: isDevelopment ? 10000 : 100, // Dev: virtually unlimited, Prod: 100/min + windowMs: 60000, + maxRequests: 100 * rateLimitMultiplier, keyGenerator: getIpKey, }, -} as const; +}; /** * Cost-based rate limiting for expensive operations diff --git a/packages/lib/providers/anthropic-thinking.ts b/packages/lib/providers/anthropic-thinking.ts new file mode 100644 index 000000000..cdf7c7a43 --- /dev/null +++ b/packages/lib/providers/anthropic-thinking.ts @@ -0,0 +1,277 @@ +/** + * Anthropic extended thinking: **`user_characters.settings`** per agent + optional deploy env defaults/caps. + * + * **Why per-agent setting:** Cloud agents (characters) own their inference policy; creators enable or + * disable thinking and pick a token budget without redeploying the platform. + * **Why still use env:** `ANTHROPIC_COT_BUDGET` is the default when the character omits + * {@link ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY}. `ANTHROPIC_COT_BUDGET_MAX` optionally caps any + * effective budget so operators bound worst-case cost. API request bodies must not carry thinking budgets + * (not client-controlled). + * **Why merge helpers:** Routes set `gateway` / `google` keys; shallow merge would drop nested keys. + * + * **Spread helpers** (pick one per call site): + * - {@link mergeAnthropicCotProviderOptions} — plain `streamText` / `generateText`. + * - {@link mergeGoogleImageModalitiesWithAnthropicCot} — Gemini-style image + optional agent budget. + * - {@link mergeGatewayGroqPreferenceWithAnthropicCot} — `gateway.order` + optional agent budget. + * + * @see docs/anthropic-cot-budget.md + */ + +import type { AnthropicProviderOptions } from "@ai-sdk/anthropic"; +import type { JSONObject } from "@ai-sdk/provider"; +import { getProviderFromModel } from "@/lib/pricing"; +import type { CloudMergedProviderOptions } from "./cloud-provider-options"; + +/** + * Models that support Anthropic extended thinking. + * Only Claude 3.5 Sonnet (new) and Claude 3 Opus support extended thinking. + * Haiku, Instant, and older Claude 2 variants do not. + * Note: Patterns do not use ^ anchor to support provider-prefixed model IDs (e.g. "anthropic/claude-sonnet-4"). + */ +const EXTENDED_THINKING_MODEL_PATTERNS = [ + /claude-3-5-sonnet/, // Claude 3.5 Sonnet (all versions) + /claude-3-7-sonnet/, // Claude 3.7 Sonnet + /claude-3-opus/, // Claude 3 Opus + /claude-sonnet-4/, // Claude Sonnet 4 + /claude-opus-4/, // Claude Opus 4 +]; + +/** + * Check if the given model ID supports extended thinking. + * Not all Anthropic models support this feature. + * Handles both bare model IDs (e.g. "claude-sonnet-4") and + * provider-prefixed IDs (e.g. "anthropic/claude-sonnet-4"). + */ +export function supportsExtendedThinking(modelId: string): boolean { + const normalizedId = modelId.toLowerCase(); + return EXTENDED_THINKING_MODEL_PATTERNS.some((pattern) => pattern.test(normalizedId)); +} + +const ENV_KEY = "ANTHROPIC_COT_BUDGET"; +const ENV_MAX_KEY = "ANTHROPIC_COT_BUDGET_MAX"; + +/** `user_characters.settings` key for per-agent thinking token budget (integer ≥ 0). */ +export const ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY = "anthropicThinkingBudgetTokens"; + +/** Subset of env used for tests and callers that only pass a few keys. */ +export type AnthropicCotEnv = Record; + +export type { CloudMergedProviderOptions } from "./cloud-provider-options"; + +function parsePositiveIntStrict(raw: string, keyLabel: string): number { + const trimmed = raw.trim(); + if (trimmed === "") { + throw new Error(`${keyLabel} is non-empty but whitespace-only`); + } + if (!/^\d+$/.test(trimmed)) { + throw new Error( + `${keyLabel} must be a non-negative integer string, got: ${JSON.stringify(raw)}`, + ); + } + const n = Number.parseInt(trimmed, 10); + if (n > Number.MAX_SAFE_INTEGER) { + throw new Error(`${keyLabel} exceeds safe integer range`); + } + return n; +} + +/** + * Reads ANTHROPIC_COT_BUDGET from env. + * - unset / empty → null (off) + * - "0" or negative as string not possible with strict digit regex; 0 from digits → null + * - invalid non-empty → throws + */ +export function parseAnthropicCotBudgetFromEnv( + env: AnthropicCotEnv = process.env, +): number | null { + const raw = env[ENV_KEY]; + if (raw === undefined || raw === "") { + return null; + } + const n = parsePositiveIntStrict(raw, ENV_KEY); + if (n <= 0) { + return null; + } + return n; +} + +/** + * Optional ceiling for any effective thinking budget (env default or per-character setting). + * Unset / empty / "0" → no cap. Positive → clamp `min(effective, max)`. + */ +export function parseAnthropicCotBudgetMaxFromEnv(env: AnthropicCotEnv = process.env): number | null { + const raw = env[ENV_MAX_KEY]; + if (raw === undefined || raw === "") { + return null; + } + const n = parsePositiveIntStrict(raw, ENV_MAX_KEY); + if (n <= 0) { + return null; + } + return n; +} + +/** + * Reads {@link ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY} from character `settings` JSON. + * Invalid or missing values → `undefined` (caller should fall back to env default). + */ +export function parseThinkingBudgetFromCharacterSettings( + settings: Record | null | undefined, +): number | undefined { + if (!settings || typeof settings !== "object") { + return undefined; + } + const raw = settings[ANTHROPIC_THINKING_BUDGET_CHARACTER_SETTINGS_KEY]; + if (raw === undefined) { + return undefined; + } + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + const n = Math.trunc(raw); + if (n < 0 || n > Number.MAX_SAFE_INTEGER) { + return undefined; + } + return n; +} + +/** + * Single place that decides whether thinking runs and with how many tokens. + * + * **Why `agentThinkingBudgetTokens` wins when defined:** Stored character settings are owner-controlled; + * `0` explicitly disables even if `ANTHROPIC_COT_BUDGET` is set. **Why `undefined` falls back to env:** + * Generic routes and agents without a setting inherit deploy policy. **Why clamp with max:** Operators + * bound worst-case spend regardless of character JSON. + */ +export function resolveAnthropicThinkingBudgetTokens( + modelId: string, + env: AnthropicCotEnv, + agentThinkingBudgetTokens?: number, +): number | null { + if (getProviderFromModel(modelId) !== "anthropic") { + return null; + } + // Not all Anthropic models support extended thinking (e.g. Haiku, Instant, Claude 2) + if (!supportsExtendedThinking(modelId)) { + return null; + } + const maxCap = parseAnthropicCotBudgetMaxFromEnv(env); + let base: number | null; + if (agentThinkingBudgetTokens !== undefined) { + if (agentThinkingBudgetTokens <= 0) { + return null; + } + base = agentThinkingBudgetTokens; + } else { + base = parseAnthropicCotBudgetFromEnv(env); + } + if (base === null) { + return null; + } + if (maxCap !== null && base > maxCap) { + return maxCap; + } + return base; +} + +const anthropicThinkingOptions = (budgetTokens: number): AnthropicProviderOptions => ({ + thinking: { type: "enabled", budgetTokens }, +}); + +/** + * AI SDK / gateway fragment when budget is active and model is Anthropic. + * + * @param agentThinkingBudgetTokens When set (including `0` handled as off via {@link resolveAnthropicThinkingBudgetTokens}), + * uses the character's budget; when omitted, uses `ANTHROPIC_COT_BUDGET` only. + */ +export function anthropicThinkingProviderOptions( + modelId: string, + env: AnthropicCotEnv = process.env, + agentThinkingBudgetTokens?: number, +): { providerOptions: CloudMergedProviderOptions } | Record { + const budget = resolveAnthropicThinkingBudgetTokens(modelId, env, agentThinkingBudgetTokens); + if (budget === null) { + return {}; + } + const anthropic = anthropicThinkingOptions(budget); + return { + providerOptions: { + anthropic, + }, + }; +} + +/** + * Deep-merge nested provider keys so gateway order / google / anthropic are preserved. + * + * Note: Only `gateway`, `anthropic`, and `google` keys are deep-merged. Other provider keys + * (e.g. `openai`, `mistral`) present in both `base` and `extra` will be clobbered by the + * top-level spread. Extend the merge list below if additional providers need deep merging. + */ +export function mergeProviderOptions( + base?: { providerOptions?: CloudMergedProviderOptions }, + extra?: { providerOptions?: CloudMergedProviderOptions }, +): { providerOptions: CloudMergedProviderOptions } | Record { + const a = base?.providerOptions; + const b = extra?.providerOptions; + if (!a && !b) { + return {}; + } + const out: CloudMergedProviderOptions = { ...a, ...b }; + if (a?.gateway && b?.gateway) { + out.gateway = { ...a.gateway, ...b.gateway }; + } + if (a?.anthropic && b?.anthropic) { + out.anthropic = { ...a.anthropic, ...b.anthropic }; + } + if (a?.google && b?.google) { + out.google = { ...a.google, ...b.google }; + } + return { providerOptions: out }; +} + +/** + * Spread into `streamText` / `generateText` after model and messages. + * Equivalent to `mergeProviderOptions(undefined, anthropicThinkingProviderOptions(modelId))`. + */ +export function mergeAnthropicCotProviderOptions( + modelId: string, + env: AnthropicCotEnv = process.env, + agentThinkingBudgetTokens?: number, +): { providerOptions: CloudMergedProviderOptions } | Record { + return mergeProviderOptions( + undefined, + anthropicThinkingProviderOptions(modelId, env, agentThinkingBudgetTokens), + ); +} + +const GOOGLE_IMAGE_MODALITIES: JSONObject = { responseModalities: ["TEXT", "IMAGE"] }; + +/** + * Gemini (and similar) image generation: `google.responseModalities` plus optional COT merge. + * For non-Anthropic `modelId`, the COT fragment is empty (no-op). + */ +export function mergeGoogleImageModalitiesWithAnthropicCot( + modelId: string, + env: AnthropicCotEnv = process.env, + agentThinkingBudgetTokens?: number, +): { providerOptions: CloudMergedProviderOptions } | Record { + return mergeProviderOptions( + { providerOptions: { google: GOOGLE_IMAGE_MODALITIES } }, + anthropicThinkingProviderOptions(modelId, env, agentThinkingBudgetTokens), + ); +} + +/** + * Chat-completions-shaped forwards (e.g. `/responses`): prefer Groq in gateway order plus optional COT. + */ +export function mergeGatewayGroqPreferenceWithAnthropicCot( + modelId: string, + env: AnthropicCotEnv = process.env, + agentThinkingBudgetTokens?: number, +): { providerOptions: CloudMergedProviderOptions } | Record { + return mergeProviderOptions( + { providerOptions: { gateway: { order: ["groq"] } } }, + anthropicThinkingProviderOptions(modelId, env, agentThinkingBudgetTokens), + ); +} diff --git a/packages/lib/providers/cloud-provider-options.ts b/packages/lib/providers/cloud-provider-options.ts new file mode 100644 index 000000000..99b0c11bc --- /dev/null +++ b/packages/lib/providers/cloud-provider-options.ts @@ -0,0 +1,11 @@ +import type { JSONObject } from "@ai-sdk/provider"; + +/** + * Shape of merged `providerOptions` passed into AI SDK calls (gateway, `streamText`, forwarded bodies). + * + * **Why `Record`:** Aligns with AI SDK shared provider options so nested + * `anthropic`, `google`, and `gateway` fragments stay JSON-serializable and assignable without `any`. + * **Why a dedicated type:** `anthropic-thinking.ts` merges fragments from several routes; one alias + * keeps merges consistent and documents intent at call sites. + */ +export type CloudMergedProviderOptions = Record; diff --git a/packages/lib/providers/index.ts b/packages/lib/providers/index.ts index 7a1929644..30fae894a 100644 --- a/packages/lib/providers/index.ts +++ b/packages/lib/providers/index.ts @@ -7,6 +7,7 @@ import { GroqProvider } from "./groq"; import type { AIProvider } from "./types"; import { VercelGatewayProvider } from "./vercel-gateway"; +export * from "./anthropic-thinking"; export { GroqProvider } from "./groq"; export * from "./types"; export { VercelGatewayProvider } from "./vercel-gateway"; diff --git a/packages/lib/providers/types.ts b/packages/lib/providers/types.ts index dbd125ec2..3a5dc3497 100644 --- a/packages/lib/providers/types.ts +++ b/packages/lib/providers/types.ts @@ -2,6 +2,8 @@ * Type definitions for AI provider interfaces. */ +import type { CloudMergedProviderOptions } from "@/lib/providers/cloud-provider-options"; + /** * OpenAI-compatible chat message. */ @@ -45,11 +47,8 @@ export interface OpenAIChatRequest { seed?: number; logprobs?: boolean; top_logprobs?: number; - providerOptions?: { - gateway?: { - order?: string[]; - }; - }; + /** AI Gateway + provider-specific options (matches AI SDK `SharedV3ProviderOptions`). */ + providerOptions?: CloudMergedProviderOptions; } export interface ProviderRequestOptions { diff --git a/packages/lib/services/app-builder-ai-sdk.ts b/packages/lib/services/app-builder-ai-sdk.ts index 9c7574dd4..0ac568dbf 100644 --- a/packages/lib/services/app-builder-ai-sdk.ts +++ b/packages/lib/services/app-builder-ai-sdk.ts @@ -21,6 +21,7 @@ import { gateway } from "@ai-sdk/gateway"; import type { ModelMessage, UserModelMessage } from "ai"; import { streamText, tool } from "ai"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { buildFullAppPrompt, type FullAppTemplateType } from "@/lib/fragments/prompt"; import { logger } from "@/lib/utils/logger"; @@ -318,10 +319,14 @@ CRITICAL RULES: // Stream with tools (no execute functions - SDK v6.0.x pattern) // Use fullStream to capture ALL parts including reasoning tokens + // CoT explicitly disabled (0) to preserve temperature control for code generation. + // App Builder relies on temperature for creative variation; enabling CoT would + // silently drop temperature per @ai-sdk/anthropic behavior. const result = streamText({ model: gateway.languageModel(model), system: finalSystemPrompt, messages, + ...mergeAnthropicCotProviderOptions(model, process.env, 0), tools: { install_packages: tool({ description: diff --git a/packages/lib/services/app-promotion-assets.ts b/packages/lib/services/app-promotion-assets.ts index f9153cb2a..f5aaa35d1 100644 --- a/packages/lib/services/app-promotion-assets.ts +++ b/packages/lib/services/app-promotion-assets.ts @@ -1,6 +1,10 @@ import { gateway } from "@ai-sdk/gateway"; import { put } from "@vercel/blob"; import { generateText, streamText } from "ai"; +import { + mergeAnthropicCotProviderOptions, + mergeGoogleImageModalitiesWithAnthropicCot, +} from "@/lib/providers/anthropic-thinking"; import { z } from "zod"; import type { App } from "@/db/repositories"; import { assertSafeOutboundUrl } from "@/lib/security/outbound-url"; @@ -297,11 +301,11 @@ class AppPromotionAssetsService { try { // Use model string directly (not gateway.languageModel) for image generation // This matches the working pattern in /api/v1/generate-image + // Note: Image generation uses Gemini models which don't support CoT, so temperature + // control is not affected by ANTHROPIC_COT_BUDGET settings. const result = streamText({ model: IMAGE_MODEL, - providerOptions: { - google: { responseModalities: ["TEXT", "IMAGE"] }, - }, + ...mergeGoogleImageModalitiesWithAnthropicCot(IMAGE_MODEL), prompt: `Generate a promotional banner image: ${prompt}`, }); @@ -458,8 +462,13 @@ Return JSON with these exact fields: Return ONLY valid JSON. No markdown, no explanation.`; + const copyModel = "anthropic/claude-sonnet-4"; + // Note: Explicitly disable extended thinking (pass 0) for ad copy generation. + // This is a background service that requires temperature control for creative output, + // and enabling CoT would silently drop temperature per @ai-sdk/anthropic behavior. const { text } = await generateText({ - model: gateway.languageModel("anthropic/claude-sonnet-4"), + model: gateway.languageModel(copyModel), + ...mergeAnthropicCotProviderOptions(copyModel, process.env, 0), temperature: 0.8, prompt, }); diff --git a/packages/lib/services/app-promotion.ts b/packages/lib/services/app-promotion.ts index 136c0315c..1761cbc11 100644 --- a/packages/lib/services/app-promotion.ts +++ b/packages/lib/services/app-promotion.ts @@ -1,5 +1,6 @@ import { gateway } from "@ai-sdk/gateway"; import { generateText } from "ai"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import type { App } from "@/db/repositories"; import { AD_COPY_GENERATION_COST, @@ -248,10 +249,15 @@ Generate the following in JSON format: Return ONLY valid JSON, no markdown.`; + const promoModel = "anthropic/claude-sonnet-4"; + // Note: When ANTHROPIC_COT_BUDGET is set, temperature is silently dropped by @ai-sdk/anthropic. + // Promotional content generation is a background service that does not benefit from extended thinking. + // Pass 0 as thinkingBudget to explicitly disable CoT for these internal service calls. const { text } = await generateText({ - model: gateway.languageModel("anthropic/claude-sonnet-4"), + model: gateway.languageModel(promoModel), temperature: 0.7, prompt, + ...mergeAnthropicCotProviderOptions(promoModel, process.env, 0), }); // Parse and validate the AI response diff --git a/packages/lib/services/seo.ts b/packages/lib/services/seo.ts index 9aee6a456..08c781a39 100644 --- a/packages/lib/services/seo.ts +++ b/packages/lib/services/seo.ts @@ -13,6 +13,7 @@ import type { SeoRequest, } from "@/db/schemas/seo"; import { seoRequests, seoRequestTypeEnum } from "@/db/schemas/seo"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { assertSafeOutboundUrl } from "@/lib/security/outbound-url"; import { logger } from "@/lib/utils/logger"; import { creditsService } from "./credits"; @@ -202,9 +203,14 @@ async function callClaudeSeoDraft( schema?: Record; }> { const modelId = "anthropic/claude-sonnet-4"; + // Note: Explicitly disable extended thinking (pass 0) for SEO generation. + // This is a background service that does not benefit from CoT, and enabling it + // would silently drop temperature control per @ai-sdk/anthropic behavior. + // Temperature 0.3 for deterministic, consistent SEO metadata output. const { text } = await generateText({ model: gateway.languageModel(modelId), temperature: 0.3, + ...mergeAnthropicCotProviderOptions(modelId, process.env, 0), system: type === "meta" ? "Generate concise SEO metadata JSON with keys: title, description, keywords (array), metaTags (object). Keep title <= 60 chars, description <= 155 chars." diff --git a/packages/lib/services/twitter-automation/app-automation.ts b/packages/lib/services/twitter-automation/app-automation.ts index 8140136b2..15fa284bb 100644 --- a/packages/lib/services/twitter-automation/app-automation.ts +++ b/packages/lib/services/twitter-automation/app-automation.ts @@ -1,5 +1,6 @@ import { gateway } from "@ai-sdk/gateway"; import { generateText } from "ai"; +import { mergeAnthropicCotProviderOptions } from "@/lib/providers/anthropic-thinking"; import { TwitterApi } from "twitter-api-v2"; import { type App, appsRepository } from "@/db/repositories"; import { TWITTER_POST_COST } from "@/lib/promotion-pricing"; @@ -232,8 +233,14 @@ Requirements: Return ONLY the tweet text, nothing else.`; try { + const twModel = "anthropic/claude-sonnet-4"; + // Note: Explicitly disable extended thinking (pass 0) for tweet generation. + // This is a background service that requires temperature control for creative output, + // and enabling CoT would silently drop temperature per @ai-sdk/anthropic behavior. + // Temperature 0.8 for varied, creative tweet content. const { text } = await generateText({ - model: gateway.languageModel("anthropic/claude-sonnet-4"), + model: gateway.languageModel(twModel), + ...mergeAnthropicCotProviderOptions(twModel, process.env, 0), temperature: 0.8, prompt, }); diff --git a/packages/scripts/check-types-split.ts b/packages/scripts/check-types-split.ts index 35bb37990..32082b8d1 100644 --- a/packages/scripts/check-types-split.ts +++ b/packages/scripts/check-types-split.ts @@ -49,11 +49,15 @@ async function splitIntoSubdirectories(dir: string): Promise { } async function getDirectoriesToCheck(): Promise { - const libSubdirs = await splitIntoSubdirectories("lib"); const appSubdirs = await splitIntoSubdirectories("app"); - const componentSubdirs = await splitIntoSubdirectories("components"); - - return ["db", ...libSubdirs, ...componentSubdirs, ...appSubdirs]; + const libSubdirs = await splitIntoSubdirectories("packages/lib"); + + return [ + "packages/db", + ...libSubdirs, + "packages/ui/src/components", + ...appSubdirs, + ]; } async function createTempTsconfig(directory: string, baseTsconfig: object): Promise { @@ -93,6 +97,8 @@ async function createTempTsconfig(directory: string, baseTsconfig: object): Prom resolve(workspaceRoot, "**/__tests__/**"), resolve(workspaceRoot, "**/*.test.ts"), resolve(workspaceRoot, "**/*.test.tsx"), + resolve(workspaceRoot, "**/*.stories.ts"), + resolve(workspaceRoot, "**/*.stories.tsx"), resolve(workspaceRoot, ".next"), resolve(workspaceRoot, "out"), resolve(workspaceRoot, "build"), diff --git a/packages/services/gateway-discord/src/gateway-manager.ts b/packages/services/gateway-discord/src/gateway-manager.ts index a46562c1e..a2f6057b4 100644 --- a/packages/services/gateway-discord/src/gateway-manager.ts +++ b/packages/services/gateway-discord/src/gateway-manager.ts @@ -1,11 +1,22 @@ import { Redis } from "@upstash/redis"; import { Client, + type Attachment, type ClientOptions, + type Embed, Events, GatewayIntentBits, + type GuildMember, + Interaction, Message, + MessageReaction, Partials, + type PartialGuildMember, + type PartialMessage, + type PartialMessageReaction, + Role, + type User, + type PartialUser, } from "discord.js"; import { logger } from "./logger"; import { @@ -746,6 +757,7 @@ export class GatewayManager { applicationId: string; botToken: string; intents: number; + characterId: string | null; }): Promise { logger.info("Connecting bot", { connectionId: assignment.connectionId, @@ -792,7 +804,7 @@ export class GatewayManager { }); } }; - conn.listeners.set(eventName, wrappedHandler as (...args: unknown[]) => void); + conn.listeners.set(eventName, wrappedHandler as unknown as (...args: unknown[]) => void); return wrappedHandler; }; @@ -828,29 +840,32 @@ export class GatewayManager { client.on( Events.MessageUpdate, - createHandler(Events.MessageUpdate, async (_oldMessage, newMessage) => { - conn.eventsReceived++; - if (newMessage.partial) return; - await this.forwardEvent(assignment.connectionId, conn, "MESSAGE_UPDATE", { - id: newMessage.id, - channel_id: newMessage.channelId, - guild_id: newMessage.guildId, - content: newMessage.content, - edited_timestamp: newMessage.editedAt?.toISOString(), - author: newMessage.author - ? { - id: newMessage.author.id, - username: newMessage.author.username, - bot: newMessage.author.bot, - } - : undefined, - }); - }), + createHandler( + Events.MessageUpdate, + async (_oldMessage: Message | PartialMessage, newMessage: Message | PartialMessage) => { + conn.eventsReceived++; + if (newMessage.partial) return; + await this.forwardEvent(assignment.connectionId, conn, "MESSAGE_UPDATE", { + id: newMessage.id, + channel_id: newMessage.channelId, + guild_id: newMessage.guildId, + content: newMessage.content, + edited_timestamp: newMessage.editedAt?.toISOString(), + author: newMessage.author + ? { + id: newMessage.author.id, + username: newMessage.author.username, + bot: newMessage.author.bot, + } + : undefined, + }); + }, + ), ); client.on( Events.MessageDelete, - createHandler(Events.MessageDelete, async (message) => { + createHandler(Events.MessageDelete, async (message: Message | PartialMessage) => { conn.eventsReceived++; await this.forwardEvent(assignment.connectionId, conn, "MESSAGE_DELETE", { id: message.id, @@ -862,21 +877,24 @@ export class GatewayManager { client.on( Events.MessageReactionAdd, - createHandler(Events.MessageReactionAdd, async (reaction, user) => { - conn.eventsReceived++; - await this.forwardEvent(assignment.connectionId, conn, "MESSAGE_REACTION_ADD", { - message_id: reaction.message.id, - channel_id: reaction.message.channelId, - guild_id: reaction.message.guildId, - emoji: { name: reaction.emoji.name, id: reaction.emoji.id }, - user_id: user.id, - }); - }), + createHandler( + Events.MessageReactionAdd, + async (reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser) => { + conn.eventsReceived++; + await this.forwardEvent(assignment.connectionId, conn, "MESSAGE_REACTION_ADD", { + message_id: reaction.message.id, + channel_id: reaction.message.channelId, + guild_id: reaction.message.guildId, + emoji: { name: reaction.emoji.name, id: reaction.emoji.id }, + user_id: user.id, + }); + }, + ), ); client.on( Events.GuildMemberAdd, - createHandler(Events.GuildMemberAdd, async (member) => { + createHandler(Events.GuildMemberAdd, async (member: GuildMember) => { conn.eventsReceived++; await this.forwardEvent(assignment.connectionId, conn, "GUILD_MEMBER_ADD", { guild_id: member.guild.id, @@ -888,7 +906,7 @@ export class GatewayManager { bot: member.user.bot, }, nick: member.nickname, - roles: member.roles.cache.map((r) => r.id), + roles: member.roles.cache.map((r: Role) => r.id), joined_at: member.joinedAt?.toISOString(), }); }), @@ -896,7 +914,7 @@ export class GatewayManager { client.on( Events.GuildMemberRemove, - createHandler(Events.GuildMemberRemove, async (member) => { + createHandler(Events.GuildMemberRemove, async (member: GuildMember | PartialGuildMember) => { conn.eventsReceived++; await this.forwardEvent(assignment.connectionId, conn, "GUILD_MEMBER_REMOVE", { guild_id: member.guild.id, @@ -911,7 +929,7 @@ export class GatewayManager { client.on( Events.InteractionCreate, - createHandler(Events.InteractionCreate, async (interaction) => { + createHandler(Events.InteractionCreate, async (interaction: Interaction) => { conn.eventsReceived++; await this.forwardEvent(assignment.connectionId, conn, "INTERACTION_CREATE", { id: interaction.id, @@ -923,13 +941,12 @@ export class GatewayManager { username: interaction.user.username, bot: interaction.user.bot, }, - data: - "commandName" in interaction - ? { - name: interaction.commandName, - options: "options" in interaction ? interaction.options.data : undefined, - } - : undefined, + data: interaction.isChatInputCommand() + ? { + name: interaction.commandName, + options: interaction.options.data, + } + : undefined, }); }), ); @@ -1069,25 +1086,25 @@ export class GatewayManager { member: message.member ? { nick: message.member.nickname, - roles: message.member.roles.cache.map((r) => r.id), + roles: message.member.roles.cache.map((r: Role) => r.id), } : undefined, content: message.content, timestamp: message.createdAt.toISOString(), - attachments: message.attachments.map((a) => ({ + attachments: message.attachments.map((a: Attachment) => ({ id: a.id, filename: a.name, url: a.url, content_type: a.contentType, size: a.size, })), - embeds: message.embeds.map((e) => ({ + embeds: message.embeds.map((e: Embed) => ({ title: e.title, description: e.description, url: e.url, color: e.color, })), - mentions: message.mentions.users.map((u) => ({ + mentions: message.mentions.users.map((u: User) => ({ id: u.id, username: u.username, bot: u.bot, @@ -1156,7 +1173,10 @@ export class GatewayManager { try { await refreshKedaActivity(this.redis, route.serverName); - await message.channel.sendTyping(); + const { channel } = message; + if ("sendTyping" in channel && typeof channel.sendTyping === "function") { + await channel.sendTyping(); + } const userId = `discord-user-${message.author.id}`; const response = await forwardToServer( diff --git a/packages/tests/e2e/setup-server.ts b/packages/tests/e2e/setup-server.ts index bddf1987a..12fdf7fb5 100644 --- a/packages/tests/e2e/setup-server.ts +++ b/packages/tests/e2e/setup-server.ts @@ -9,7 +9,7 @@ const HEALTHCHECK_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 500; const MANAGED_FETCH_RETRIES = 4; const TEST_SERVER_SCRIPT = process.env.TEST_SERVER_SCRIPT || "dev"; -const baseFetch: typeof fetch = globalThis.fetch.bind(globalThis); +const baseFetch: typeof fetch = globalThis.fetch; let serverProcess: Subprocess | null = null; let startedServer = false; @@ -182,8 +182,14 @@ export async function ensureServer(): Promise { }, }); - pipeServerLogs(serverProcess.stdout, "stdout"); - pipeServerLogs(serverProcess.stderr, "stderr"); + pipeServerLogs( + serverProcess.stdout instanceof ReadableStream ? serverProcess.stdout : null, + "stdout", + ); + pipeServerLogs( + serverProcess.stderr instanceof ReadableStream ? serverProcess.stderr : null, + "stderr", + ); watchServerExit(serverProcess); try { @@ -235,34 +241,39 @@ function isRecoverableServerError(error: unknown): boolean { ); } -const fetchWithServer: typeof fetch = async (input, init) => { - const requestUrl = getRequestUrl(input); - const isManagedRequest = requestUrl.startsWith(SERVER_URL); +const fetchWithServer: typeof fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = getRequestUrl(input); + const isManagedRequest = requestUrl.startsWith(SERVER_URL); - if (!isManagedRequest) { - return await baseFetch(input, init); - } + if (!isManagedRequest) { + return await baseFetch(input, init); + } - const nextRequest = createRequestFactory(input, init); + const nextRequest = createRequestFactory(input, init); - for (let attempt = 0; attempt < MANAGED_FETCH_RETRIES; attempt += 1) { - try { - await ensureServer(); + for (let attempt = 0; attempt < MANAGED_FETCH_RETRIES; attempt += 1) { + try { + await ensureServer(); + + const [requestInput, requestInit] = nextRequest(); + return await baseFetch(requestInput, requestInit); + } catch (error) { + const isLastAttempt = attempt === MANAGED_FETCH_RETRIES - 1; + if (!isRecoverableServerError(error) || isLastAttempt) { + throw error; + } - const [requestInput, requestInit] = nextRequest(); - return await baseFetch(requestInput, requestInit); - } catch (error) { - const isLastAttempt = attempt === MANAGED_FETCH_RETRIES - 1; - if (!isRecoverableServerError(error) || isLastAttempt) { - throw error; + await Bun.sleep(POLL_INTERVAL_MS * (attempt + 1)); } - - await Bun.sleep(POLL_INTERVAL_MS * (attempt + 1)); } - } - throw new Error("Managed fetch exhausted all retry attempts"); -}; + throw new Error("Managed fetch exhausted all retry attempts"); + }, + typeof baseFetch.preconnect === "function" + ? { preconnect: baseFetch.preconnect.bind(baseFetch) } + : {}, +); globalThis.fetch = fetchWithServer; diff --git a/packages/tests/fixtures/mcp-test-character.ts b/packages/tests/fixtures/mcp-test-character.ts index 6643276a4..1a7fd58e7 100644 --- a/packages/tests/fixtures/mcp-test-character.ts +++ b/packages/tests/fixtures/mcp-test-character.ts @@ -103,7 +103,7 @@ export const mcpTestCharacter: Character = { * A simpler test character without MCP for baseline testing */ export const simpleTestCharacter: Character = { - id: "test-agent-simple-001", + id: "aaaaaaaa-bbbb-4ccc-bddd-eeeeeeeeeeee", name: "TestAgent", system: "You are a helpful test agent. Respond concisely.", bio: "A simple test agent for integration testing.", diff --git a/packages/tests/helpers/index.ts b/packages/tests/helpers/index.ts index 9f1130811..ae671945a 100644 --- a/packages/tests/helpers/index.ts +++ b/packages/tests/helpers/index.ts @@ -1 +1,2 @@ export * from "../infrastructure"; +export * from "./mock-milady-pricing-for-route-tests"; diff --git a/packages/tests/helpers/mock-milady-pricing-for-route-tests.ts b/packages/tests/helpers/mock-milady-pricing-for-route-tests.ts new file mode 100644 index 000000000..5d1db5d37 --- /dev/null +++ b/packages/tests/helpers/mock-milady-pricing-for-route-tests.ts @@ -0,0 +1,23 @@ +import { mock } from "bun:test"; + +import { MILADY_PRICING as realMiladyPricing } from "@/lib/constants/milady-pricing"; + +/** + * Registers `mock.module("@/lib/constants/milady-pricing", …)` for tests that only need a different + * {@link realMiladyPricing.MINIMUM_DEPOSIT}. + * + * **Why not** `MILADY_PRICING: { MINIMUM_DEPOSIT: n }` alone? `mock.module` replaces the whole module. + * Any later importer (notably `app/api/cron/milady-billing/route.ts`) would lose `RUNNING_HOURLY_RATE`, + * `LOW_CREDIT_WARNING`, `GRACE_PERIOD_HOURS`, etc., so full `bun run test:unit` can fail while a + * single-file run passes. Spreading `realMiladyPricing` keeps one source of truth. + * + * @see docs/unit-testing-milady-mocks.md + */ +export function mockMiladyPricingMinimumDepositForRouteTests(minimumDeposit = 5): void { + mock.module("@/lib/constants/milady-pricing", () => ({ + MILADY_PRICING: { + ...realMiladyPricing, + MINIMUM_DEPOSIT: minimumDeposit, + }, + })); +} diff --git a/packages/tests/integration/financial/concurrent-operations.test.ts b/packages/tests/integration/financial/concurrent-operations.test.ts index 5d4050b30..aa64377a2 100644 --- a/packages/tests/integration/financial/concurrent-operations.test.ts +++ b/packages/tests/integration/financial/concurrent-operations.test.ts @@ -143,7 +143,6 @@ describe("Cross-Service Concurrent Operations", () => { organizationId: orgId, amount: 10, description: "Stress add", - source: "manual", }), ), ...Array.from({ length: 10 }, () => diff --git a/packages/tests/integration/financial/credits-budget-flow.test.ts b/packages/tests/integration/financial/credits-budget-flow.test.ts index 17f982ad3..f153f2508 100644 --- a/packages/tests/integration/financial/credits-budget-flow.test.ts +++ b/packages/tests/integration/financial/credits-budget-flow.test.ts @@ -190,7 +190,6 @@ describe("Credits-Budget Flow Integration", () => { organizationId: orgId, amount: 100, description: "Manual top-up", - source: "manual", }); // Now allocation should work diff --git a/packages/tests/integration/mcp-registry.test.ts b/packages/tests/integration/mcp-registry.test.ts index 5c35a045c..3824c462f 100644 --- a/packages/tests/integration/mcp-registry.test.ts +++ b/packages/tests/integration/mcp-registry.test.ts @@ -16,7 +16,7 @@ const SERVER_URL = process.env.TEST_SERVER_URL || "http://localhost:3000"; // The registry route pulls in a large dependency graph and can exceed the // default request timeout on cold CI webpack compilations. const TIMEOUT = 30000; -const test = (name: string, fn: () => unknown | Promise) => bunTest(name, fn, TIMEOUT); +const test = (name: string, fn: () => void | Promise) => bunTest(name, fn, TIMEOUT); interface McpRegistryEntry { id: string; diff --git a/packages/tests/integration/server-wallets.test.ts b/packages/tests/integration/server-wallets.test.ts index ce235e283..d33aefc60 100644 --- a/packages/tests/integration/server-wallets.test.ts +++ b/packages/tests/integration/server-wallets.test.ts @@ -99,7 +99,20 @@ describe("server-wallets service", () => { address: "0xabc", }); const mockInsertValues = mock().mockReturnValue({ - returning: mock().mockResolvedValue([{ id: 1, address: "0xabc" }]), + returning: mock().mockResolvedValue([ + { + id: "aaaaaaaa-bbbb-4ccc-bddd-eeeeeeeeeeee", + organization_id: "org1", + user_id: "user1", + character_id: "char1", + privy_wallet_id: "pw_123", + address: "0xabc", + chain_type: "evm", + client_address: "0xClient", + created_at: new Date(), + updated_at: new Date(), + }, + ]), }); mockGetPrivyClient.mockReturnValue({ @@ -126,7 +139,10 @@ describe("server-wallets service", () => { client_address: "0xClient", }), ); - expect(result).toEqual({ id: 1, address: "0xabc" }); + expect(result).toMatchObject({ + id: "aaaaaaaa-bbbb-4ccc-bddd-eeeeeeeeeeee", + address: "0xabc", + }); }); }); @@ -173,7 +189,7 @@ describe("server-wallets service", () => { }), ); - expect(result).toEqual({ method: "eth_sendTransaction", data: "0xres" }); + expect(result).toMatchObject({ method: "eth_sendTransaction", data: "0xres" }); }); it("should throw if signature invalid", async () => { diff --git a/packages/tests/integration/services/organizations.service.test.ts b/packages/tests/integration/services/organizations.service.test.ts index 2c39dc505..ba99aeb9b 100644 --- a/packages/tests/integration/services/organizations.service.test.ts +++ b/packages/tests/integration/services/organizations.service.test.ts @@ -155,7 +155,7 @@ describe("OrganizationsService", () => { // The user created in testData should be associated expect(result!.users).toBeDefined(); expect(result!.users.length).toBeGreaterThanOrEqual(1); - expect(result!.users.some((u: { id: string }) => u.id === testData.user.id)).toBe(true); + expect(result!.users.map((u) => (u as { id: string }).id)).toContain(testData.user.id); // Cleanup await cleanupTestData(connectionString, testData.organization.id); diff --git a/packages/tests/integration/services/users-join-regression.test.ts b/packages/tests/integration/services/users-join-regression.test.ts index 88b81be31..faee81ffd 100644 --- a/packages/tests/integration/services/users-join-regression.test.ts +++ b/packages/tests/integration/services/users-join-regression.test.ts @@ -66,7 +66,11 @@ describe("Privy read-path regression (5c31c7732)", () => { expect(serviceUser!.organization_id).toBe(relationalUser!.organization_id); const serviceOrg = serviceUser!.organization!; - const relationalOrg = relationalUser!.organization!; + const relationalOrgRaw = relationalUser!.organization; + if (relationalOrgRaw == null || Array.isArray(relationalOrgRaw)) { + throw new Error("expected single organization relation"); + } + const relationalOrg = relationalOrgRaw; expect(serviceOrg.credit_balance).toBe(relationalOrg.credit_balance); expect(serviceOrg.id).toBe(relationalOrg.id); expect(serviceOrg.name).toBe(relationalOrg.name); diff --git a/packages/tests/integration/services/users.service.test.ts b/packages/tests/integration/services/users.service.test.ts index fca9eb0d9..80995f74c 100644 --- a/packages/tests/integration/services/users.service.test.ts +++ b/packages/tests/integration/services/users.service.test.ts @@ -126,6 +126,9 @@ describe("UsersService", () => { test("handles case sensitivity based on database collation", async () => { // Arrange const email = testData.user.email; + if (email === null || email === undefined) { + throw new Error("fixture user must have an email"); + } const uppercaseEmail = email.toUpperCase(); // Act @@ -135,7 +138,7 @@ describe("UsersService", () => { // PostgreSQL default is case-sensitive, so uppercase lookup may return undefined if (user) { // If found, emails should match (case-insensitive comparison) - expect(user.email.toLowerCase()).toBe(email.toLowerCase()); + expect((user.email ?? "").toLowerCase()).toBe(email.toLowerCase()); } else { // Case-sensitive DB - original email should still work const originalUser = await usersService.getByEmail(email); @@ -301,7 +304,8 @@ describe("UsersService", () => { const user = await usersService.getByPrivyId(privyId); expect(user).toBeDefined(); - expect([testData.user.id, secondData.user.id]).toContain(user?.id); + const winnerId = user!.id; + expect([testData.user.id, secondData.user.id]).toContain(winnerId); } finally { await cleanupTestData(connectionString, secondData.organization.id); await cleanupTestData(connectionString, testData.organization.id); diff --git a/packages/tests/integration/unified-oauth-api.test.ts b/packages/tests/integration/unified-oauth-api.test.ts index d32c3395f..8de3b47eb 100644 --- a/packages/tests/integration/unified-oauth-api.test.ts +++ b/packages/tests/integration/unified-oauth-api.test.ts @@ -74,7 +74,7 @@ function hasUsableCacheConfig(): boolean { const CACHE_CONFIGURED = hasUsableCacheConfig(); const encryptionService = createEncryptionService(); let secretsClient: Client | null = null; -const it = (name: string, fn: () => unknown | Promise) => bunIt(name, fn, TIMEOUT); +const it = (name: string, fn: () => void | Promise) => bunIt(name, fn, TIMEOUT); describe.skipIf(!TEST_DB_URL)("Unified OAuth API E2E Tests", () => { let testData: TestDataSet; diff --git a/packages/tests/integration/webhooks-e2e.test.ts b/packages/tests/integration/webhooks-e2e.test.ts index 8efeee07d..0280260b0 100644 --- a/packages/tests/integration/webhooks-e2e.test.ts +++ b/packages/tests/integration/webhooks-e2e.test.ts @@ -67,7 +67,7 @@ async function upsertSecret( ); } -const originalFetch: typeof fetch = globalThis.fetch.bind(globalThis); +const originalFetch: typeof fetch = globalThis.fetch; function getRequestUrl(input: RequestInfo | URL): string { if (typeof input === "string") { @@ -103,39 +103,44 @@ function createBlooioSignature(rawBody: string, timestamp: number): string { return `t=${timestamp},v1=${signature}`; } -const signedWebhookFetch: typeof fetch = (input, init) => { - const url = getRequestUrl(input); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); +const signedWebhookFetch: typeof fetch = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = getRequestUrl(input); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (method !== "POST") { - return originalFetch(input, init); - } + if (method !== "POST") { + return originalFetch(input, init); + } - const headers = new Headers( - init?.headers ?? (input instanceof Request ? input.headers : undefined), - ); + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); - if (headers.get("X-Test-Skip-Signature") === "true") { - headers.delete("X-Test-Skip-Signature"); - return originalFetch(input, { ...init, headers }); - } + if (headers.get("X-Test-Skip-Signature") === "true") { + headers.delete("X-Test-Skip-Signature"); + return originalFetch(input, { ...init, headers }); + } - if (url.includes("/api/webhooks/twilio/") && !headers.has("X-Twilio-Signature")) { - const body = init?.body; - if (body instanceof URLSearchParams || typeof body === "string") { - headers.set("X-Twilio-Signature", createTwilioSignature(url, body)); + if (url.includes("/api/webhooks/twilio/") && !headers.has("X-Twilio-Signature")) { + const body = init?.body; + if (body instanceof URLSearchParams || typeof body === "string") { + headers.set("X-Twilio-Signature", createTwilioSignature(url, body)); + } } - } - if (url.includes("/api/webhooks/blooio/") && !headers.has("X-Blooio-Signature")) { - const body = init?.body; - if (typeof body === "string") { - headers.set("X-Blooio-Signature", createBlooioSignature(body, Math.floor(Date.now() / 1000))); + if (url.includes("/api/webhooks/blooio/") && !headers.has("X-Blooio-Signature")) { + const body = init?.body; + if (typeof body === "string") { + headers.set("X-Blooio-Signature", createBlooioSignature(body, Math.floor(Date.now() / 1000))); + } } - } - return originalFetch(input, { ...init, headers }); -}; + return originalFetch(input, { ...init, headers }); + }, + typeof originalFetch.preconnect === "function" + ? { preconnect: originalFetch.preconnect.bind(originalFetch) } + : {}, +); describe.skipIf(!TEST_DB_URL)("Webhook Handlers E2E Tests", () => { let testData: TestDataSet; diff --git a/packages/tests/integration/x402-topup.test.ts b/packages/tests/integration/x402-topup.test.ts index 0a88cdab5..a3b96f3cb 100644 --- a/packages/tests/integration/x402-topup.test.ts +++ b/packages/tests/integration/x402-topup.test.ts @@ -5,22 +5,27 @@ const originalX402RecipientAddress = process.env.X402_RECIPIENT_ADDRESS; const mockUpdateCreditBalance = mock(); const mockApplyReferralCode = mock(); const mockCalculateRevenueSplits = mock(); -let referralsServiceForTest: { + +type ReferralsServicePatch = { applyReferralCode: typeof mockApplyReferralCode; calculateRevenueSplits: typeof mockCalculateRevenueSplits; -} | null = null; -let originalApplyReferralCode: unknown; -let originalCalculateRevenueSplits: unknown; -let organizationsServiceForTest: { +}; +type OrganizationsServicePatch = { updateCreditBalance: typeof mockUpdateCreditBalance; -} | null = null; -let originalUpdateCreditBalance: unknown; +}; +type RedeemableEarningsServicePatch = { + addEarnings: typeof mockAddEarnings; +}; + +let referralsServiceForTest: ReferralsServicePatch | null = null; +let originalApplyReferralCode: ReferralsServicePatch["applyReferralCode"] | null = null; +let originalCalculateRevenueSplits: ReferralsServicePatch["calculateRevenueSplits"] | null = null; +let organizationsServiceForTest: OrganizationsServicePatch | null = null; +let originalUpdateCreditBalance: OrganizationsServicePatch["updateCreditBalance"] | null = null; const mockAddEarnings = mock().mockResolvedValue(true); -let redeemableEarningsServiceForTest: { - addEarnings: typeof mockAddEarnings; -} | null = null; -let originalAddEarnings: unknown; +let redeemableEarningsServiceForTest: RedeemableEarningsServicePatch | null = null; +let originalAddEarnings: RedeemableEarningsServicePatch["addEarnings"] | null = null; // Register mock.module BEFORE any dynamic imports so the mock is in place // when topup-handler statically imports wallet-signup @@ -38,7 +43,7 @@ mock.module("@/lib/services/wallet-signup", () => ({ })); mock.module("x402-next", () => ({ - withX402: (handler: any) => handler, + withX402: Promise>(handler: T): T => handler, })); describe("x402 Topup Endpoints", () => { @@ -50,20 +55,22 @@ describe("x402 Topup Endpoints", () => { const actualReferralsModule = await import("@/lib/services/referrals"); const actualOrganizationsModule = await import("@/lib/services/organizations"); const actualRedeemableEarningsModule = await import("@/lib/services/redeemable-earnings"); - referralsServiceForTest = - actualReferralsModule.referralsService as typeof referralsServiceForTest; - originalApplyReferralCode = referralsServiceForTest.applyReferralCode; - originalCalculateRevenueSplits = referralsServiceForTest.calculateRevenueSplits; - referralsServiceForTest.applyReferralCode = mockApplyReferralCode; - referralsServiceForTest.calculateRevenueSplits = mockCalculateRevenueSplits; - organizationsServiceForTest = - actualOrganizationsModule.organizationsService as typeof organizationsServiceForTest; - originalUpdateCreditBalance = organizationsServiceForTest.updateCreditBalance; - organizationsServiceForTest.updateCreditBalance = mockUpdateCreditBalance; - redeemableEarningsServiceForTest = - actualRedeemableEarningsModule.redeemableEarningsService as typeof redeemableEarningsServiceForTest; - originalAddEarnings = redeemableEarningsServiceForTest.addEarnings; - redeemableEarningsServiceForTest.addEarnings = mockAddEarnings; + const referrals: ReferralsServicePatch = actualReferralsModule.referralsService as ReferralsServicePatch; + referralsServiceForTest = referrals; + originalApplyReferralCode = referrals.applyReferralCode; + originalCalculateRevenueSplits = referrals.calculateRevenueSplits; + referrals.applyReferralCode = mockApplyReferralCode; + referrals.calculateRevenueSplits = mockCalculateRevenueSplits; + + const orgs: OrganizationsServicePatch = actualOrganizationsModule.organizationsService as OrganizationsServicePatch; + organizationsServiceForTest = orgs; + originalUpdateCreditBalance = orgs.updateCreditBalance; + orgs.updateCreditBalance = mockUpdateCreditBalance; + + const redeem: RedeemableEarningsServicePatch = actualRedeemableEarningsModule.redeemableEarningsService as RedeemableEarningsServicePatch; + redeemableEarningsServiceForTest = redeem; + originalAddEarnings = redeem.addEarnings; + redeem.addEarnings = mockAddEarnings; }); beforeEach(() => { @@ -88,18 +95,22 @@ describe("x402 Topup Endpoints", () => { }); afterAll(() => { - if (referralsServiceForTest) { - referralsServiceForTest.applyReferralCode = - originalApplyReferralCode as typeof mockApplyReferralCode; - referralsServiceForTest.calculateRevenueSplits = - originalCalculateRevenueSplits as typeof mockCalculateRevenueSplits; + // Restore referrals service methods if they were patched + if (referralsServiceForTest !== null) { + if (originalApplyReferralCode !== null) { + referralsServiceForTest.applyReferralCode = originalApplyReferralCode; + } + if (originalCalculateRevenueSplits !== null) { + referralsServiceForTest.calculateRevenueSplits = originalCalculateRevenueSplits; + } } - if (organizationsServiceForTest) { - organizationsServiceForTest.updateCreditBalance = - originalUpdateCreditBalance as typeof mockUpdateCreditBalance; + // Restore organizations service methods if they were patched + if (organizationsServiceForTest !== null && originalUpdateCreditBalance !== null) { + organizationsServiceForTest.updateCreditBalance = originalUpdateCreditBalance; } - if (redeemableEarningsServiceForTest) { - redeemableEarningsServiceForTest.addEarnings = originalAddEarnings as typeof mockAddEarnings; + // Restore redeemable earnings service methods if they were patched + if (redeemableEarningsServiceForTest !== null && originalAddEarnings !== null) { + redeemableEarningsServiceForTest.addEarnings = originalAddEarnings; } if (originalX402RecipientAddress === undefined) { delete process.env.X402_RECIPIENT_ADDRESS; @@ -116,7 +127,7 @@ describe("x402 Topup Endpoints", () => { body: JSON.stringify({ walletAddress: mockWallet }), }); - const response = await POST10(req as any); + const response = await POST10(req); const data = await response.json(); expect(response.status).toBe(200); @@ -133,7 +144,7 @@ describe("x402 Topup Endpoints", () => { }); const { POST: POST50 } = await import("@/app/api/v1/topup/50/route"); - const response = await POST50(req as any); + const response = await POST50(req); const data = await response.json(); expect(response.status).toBe(200); @@ -157,7 +168,7 @@ describe("x402 Topup Endpoints", () => { }, ); - const response = await POST10(req as any); + const response = await POST10(req); expect(response.status).toBe(200); expect(mockApplyReferralCode).toHaveBeenCalledWith(mockUserId, mockOrgId, "ABCD-1234", { @@ -181,7 +192,7 @@ describe("x402 Topup Endpoints", () => { body: JSON.stringify({}), }); - const response = await POST100(req as any); + const response = await POST100(req); const data = await response.json(); expect(response.status).toBe(400); diff --git a/packages/tests/load-env.ts b/packages/tests/load-env.ts index 417823aa7..2fd48ef6f 100644 --- a/packages/tests/load-env.ts +++ b/packages/tests/load-env.ts @@ -12,7 +12,7 @@ config({ path: resolve(root, ".env.local") }); config({ path: resolve(root, ".env.test") }); // Keep all test execution pinned to the local app surface. -process.env.NODE_ENV = "test"; +(process.env as Record).NODE_ENV = "test"; process.env.ELIZAOS_CLOUD_BASE_URL = "http://localhost:3000/api/v1"; process.env.TEST_BLOCK_ANONYMOUS = "true"; diff --git a/packages/tests/playwright/fixtures/auth.fixture.ts b/packages/tests/playwright/fixtures/auth.fixture.ts index 89b59b047..60f96aa44 100644 --- a/packages/tests/playwright/fixtures/auth.fixture.ts +++ b/packages/tests/playwright/fixtures/auth.fixture.ts @@ -8,7 +8,7 @@ * - Authenticated flows require TEST_API_KEY env var */ -import { test as base, expect } from "@playwright/test"; +import { test as base, expect, type APIRequestContext } from "@playwright/test"; const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000"; const API_KEY = process.env.TEST_API_KEY; @@ -32,9 +32,9 @@ export function hasApiKey(): boolean { * Returns the token that can be used for X-Anonymous-Session header. */ export async function createAnonymousSession( - request: ReturnType["request"] extends infer R ? R : never, + request: APIRequestContext, ): Promise<{ sessionToken: string; userId: string }> { - const response = await (request as any).post(`${BASE_URL}/api/auth/create-anonymous-session`, { + const response = await request.post(`${BASE_URL}/api/auth/create-anonymous-session`, { headers: { "Content-Type": "application/json" }, }); expect(response.status()).toBe(200); diff --git a/packages/tests/runtime/integration/message-handler/mcp-tools.test.ts b/packages/tests/runtime/integration/message-handler/mcp-tools.test.ts index e08e846bf..73cb9d797 100644 --- a/packages/tests/runtime/integration/message-handler/mcp-tools.test.ts +++ b/packages/tests/runtime/integration/message-handler/mcp-tools.test.ts @@ -364,7 +364,7 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () timeoutMs: 120000, debug: { enabled: debugEnabled, - renderView: "detail", + renderView: "full", storeTrace: true, }, }, @@ -392,7 +392,7 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () console.log("DEBUG TRACE"); console.log("=".repeat(60)); - const markdown = renderDebugTrace(trace, "detail"); + const markdown = renderDebugTrace(trace, "full"); console.log(markdown); console.log("\n" + "=".repeat(60)); @@ -404,11 +404,12 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () console.log(` Steps: ${trace.steps?.length || 0}`); console.log(` Duration: ${trace.endedAt ? trace.endedAt - trace.startedAt : "N/A"}ms`); - if (trace.failure) { + const fail = trace.failures[0]; + if (fail) { console.log(`\nFailure detected:`); - console.log(` Type: ${trace.failure.type}`); - console.log(` Message: ${trace.failure.message}`); - console.log(` Step: ${trace.failure.step}`); + console.log(` Type: ${fail.type}`); + console.log(` Message: ${fail.message}`); + console.log(` Step: ${fail.stepIndex}`); } } else { console.log("\nNo debug trace captured (trace may not have been generated)"); diff --git a/packages/tests/runtime/integration/performance/runtime-creation.test.ts b/packages/tests/runtime/integration/performance/runtime-creation.test.ts index 5eb2bb057..e3e80d673 100644 --- a/packages/tests/runtime/integration/performance/runtime-creation.test.ts +++ b/packages/tests/runtime/integration/performance/runtime-creation.test.ts @@ -214,7 +214,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { try { await testRuntime.runtime.createEntity({ id: uuidv4() as UUID, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, names: [`PerfEntity${i}`], metadata: { type: "test", index: i }, }); @@ -242,7 +242,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { { id: uuidv4() as UUID, entityId: testUser.entityId, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, roomId: testUser.roomId, content: { text: `Performance test message ${i}` }, createdAt: Date.now(), @@ -267,7 +267,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { { id: uuidv4() as UUID, entityId: testUser.entityId, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, roomId: testUser.roomId, content: { text: `Retrieval test message ${i}` }, createdAt: Date.now(), diff --git a/packages/tests/runtime/integration/runtime-factory/oauth-cache-invalidation.test.ts b/packages/tests/runtime/integration/runtime-factory/oauth-cache-invalidation.test.ts index 574e2d9ad..c98c75fc7 100644 --- a/packages/tests/runtime/integration/runtime-factory/oauth-cache-invalidation.test.ts +++ b/packages/tests/runtime/integration/runtime-factory/oauth-cache-invalidation.test.ts @@ -376,7 +376,16 @@ describe.skipIf(!hasDatabaseUrl)("OAuth flow cache invalidation integration", () const runtime2 = await runtimeFactory.createRuntimeForUser(userContext2); // 4. Verify MCP settings are now present - const mcpSettings = runtime2.settings?.mcp || (runtime2.character as any)?.settings?.mcp; + type McpServerConfig = { + url?: string; + type?: string; + headers?: Record; + }; + const mcpSettings = ( + runtime2.character as { + settings?: { mcp?: { servers?: Record } }; + } + ).settings?.mcp; expect(mcpSettings).toBeDefined(); expect(mcpSettings?.servers?.google).toBeDefined(); diff --git a/packages/tests/runtime/mcp-assistant-trending.test.ts b/packages/tests/runtime/mcp-assistant-trending.test.ts index ad9cddb25..85033871d 100644 --- a/packages/tests/runtime/mcp-assistant-trending.test.ts +++ b/packages/tests/runtime/mcp-assistant-trending.test.ts @@ -209,7 +209,7 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () timeoutMs: 120000, debug: { enabled: debugEnabled, - renderView: "detail", + renderView: "full", storeTrace: true, }, }, @@ -237,7 +237,7 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () console.log("🔍 DEBUG TRACE"); console.log("=".repeat(60)); - const markdown = renderDebugTrace(trace, "detail"); + const markdown = renderDebugTrace(trace, "full"); console.log(markdown); console.log("\n" + "=".repeat(60)); @@ -249,11 +249,12 @@ describe.skipIf(skipLiveModelSuite)("MCP Assistant - Trending Tokens Query", () console.log(` Steps: ${trace.steps?.length || 0}`); console.log(` Duration: ${trace.endedAt ? trace.endedAt - trace.startedAt : "N/A"}ms`); - if (trace.failure) { + const fail = trace.failures[0]; + if (fail) { console.log(`\n⚠️ Failure detected:`); - console.log(` Type: ${trace.failure.type}`); - console.log(` Message: ${trace.failure.message}`); - console.log(` Step: ${trace.failure.step}`); + console.log(` Type: ${fail.type}`); + console.log(` Message: ${fail.message}`); + console.log(` Step: ${fail.stepIndex}`); } } else { console.log("\n⚠️ No debug trace captured (trace may not have been generated)"); diff --git a/packages/tests/runtime/performance.test.ts b/packages/tests/runtime/performance.test.ts index bc26ba281..d0870bfcc 100644 --- a/packages/tests/runtime/performance.test.ts +++ b/packages/tests/runtime/performance.test.ts @@ -175,7 +175,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { try { await testRuntime.runtime.createEntity({ id: uuidv4() as UUID, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, names: [`PerfEntity${i}`], metadata: { type: "test", index: i }, }); @@ -203,7 +203,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { { id: uuidv4() as UUID, entityId: testUser.entityId, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, roomId: testUser.roomId, content: { text: `Performance test message ${i}` }, createdAt: Date.now(), @@ -228,7 +228,7 @@ describe.skipIf(!hasDatabaseUrl)("Database Query Performance", () => { { id: uuidv4() as UUID, entityId: testUser.entityId, - agentId: testRuntime.agentId, + agentId: testRuntime.agentId as UUID, roomId: testUser.roomId, content: { text: `Retrieval test message ${i}` }, createdAt: Date.now(), diff --git a/packages/tests/unit/admin-service-pricing-route.test.ts b/packages/tests/unit/admin-service-pricing-route.test.ts index fca78aee9..3b5895302 100644 --- a/packages/tests/unit/admin-service-pricing-route.test.ts +++ b/packages/tests/unit/admin-service-pricing-route.test.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import { NextRequest, NextResponse } from "next/server"; -const mockRequireAdminWithResponse = mock(async () => ({ - user: { id: "admin-1" }, - role: "admin", -})); +type AdminPricingAuthResult = + | { user: { id: string }; role: string } + | NextResponse; + +const mockRequireAdminWithResponse = mock( + async (): Promise => ({ + user: { id: "admin-1" }, + role: "admin", + }), +); const mockListByService = mock(async () => [] as any[]); const mockUpsert = mock(async () => ({ @@ -61,12 +67,15 @@ async function importRoute() { } function createRequest(method: string, url: string, body?: unknown): NextRequest { - const init: RequestInit = { method }; + const u = new URL(url, "http://localhost"); if (body !== undefined) { - init.body = JSON.stringify(body); - init.headers = { "Content-Type": "application/json" }; + return new NextRequest(u, { + method, + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); } - return new NextRequest(url, init); + return new NextRequest(u, { method }); } describe("Admin Service Pricing API", () => { diff --git a/packages/tests/unit/anthropic-thinking.test.ts b/packages/tests/unit/anthropic-thinking.test.ts new file mode 100644 index 000000000..d54bbe90a --- /dev/null +++ b/packages/tests/unit/anthropic-thinking.test.ts @@ -0,0 +1,365 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + anthropicThinkingProviderOptions, + mergeAnthropicCotProviderOptions, + mergeGatewayGroqPreferenceWithAnthropicCot, + mergeGoogleImageModalitiesWithAnthropicCot, + mergeProviderOptions, + parseAnthropicCotBudgetFromEnv, + parseAnthropicCotBudgetMaxFromEnv, + parseThinkingBudgetFromCharacterSettings, + resolveAnthropicThinkingBudgetTokens, +} from "@/lib/providers/anthropic-thinking"; + +const COT_ENV_KEY = "ANTHROPIC_COT_BUDGET"; +const COT_MAX_ENV_KEY = "ANTHROPIC_COT_BUDGET_MAX"; + +describe("resolveAnthropicThinkingBudgetTokens", () => { + let prevBudget: string | undefined; + let prevMax: string | undefined; + + beforeEach(() => { + prevBudget = process.env[COT_ENV_KEY]; + prevMax = process.env[COT_MAX_ENV_KEY]; + delete process.env[COT_ENV_KEY]; + delete process.env[COT_MAX_ENV_KEY]; + }); + + afterEach(() => { + if (prevBudget === undefined) { + delete process.env[COT_ENV_KEY]; + } else { + process.env[COT_ENV_KEY] = prevBudget; + } + if (prevMax === undefined) { + delete process.env[COT_MAX_ENV_KEY]; + } else { + process.env[COT_MAX_ENV_KEY] = prevMax; + } + }); + + test("returns null for non-Anthropic model", () => { + const result = resolveAnthropicThinkingBudgetTokens("openai/gpt-4", {}); + expect(result).toBeNull(); + }); + + test("returns null for Anthropic model that does not support extended thinking", () => { + const result = resolveAnthropicThinkingBudgetTokens("anthropic/claude-3-haiku", {}); + expect(result).toBeNull(); + }); + + test("uses per-agent budget when provided for supported Anthropic model", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + {}, + 5000, + ); + expect(result).toBe(5000); + }); + + test("returns null when per-agent budget is 0 (explicitly disabled)", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_ENV_KEY]: "10000" }, + 0, + ); + expect(result).toBeNull(); + }); + + test("falls back to env budget when per-agent budget is undefined", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_ENV_KEY]: "8000" }, + ); + expect(result).toBe(8000); + }); + + test("returns null when both per-agent and env budgets are unset", () => { + const result = resolveAnthropicThinkingBudgetTokens("anthropic/claude-sonnet-4", {}); + expect(result).toBeNull(); + }); + + test("clamps budget to max cap when max is set and budget exceeds it", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_MAX_ENV_KEY]: "3000" }, + 5000, + ); + expect(result).toBe(3000); + }); + + test("does not clamp budget when under max cap", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_MAX_ENV_KEY]: "10000" }, + 5000, + ); + expect(result).toBe(5000); + }); + + test("clamps env fallback budget to max cap", () => { + const result = resolveAnthropicThinkingBudgetTokens("anthropic/claude-sonnet-4", { + [COT_ENV_KEY]: "15000", + [COT_MAX_ENV_KEY]: "10000", + }); + expect(result).toBe(10000); + }); + + test("returns null when env budget is 0 (explicitly disabled)", () => { + const result = resolveAnthropicThinkingBudgetTokens("anthropic/claude-sonnet-4", { + [COT_ENV_KEY]: "0", + }); + expect(result).toBeNull(); + }); + + test("per-agent budget takes precedence over env budget", () => { + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_ENV_KEY]: "5000" }, + 3000, + ); + expect(result).toBe(3000); + }); + + test("max cap of 0 means no cap, per-agent budget passes through", () => { + // parseAnthropicCotBudgetMaxFromEnv returns null for "0" (meaning no cap), + // so the per-agent budget of 5000 is returned unchanged. + const result = resolveAnthropicThinkingBudgetTokens( + "anthropic/claude-sonnet-4", + { [COT_MAX_ENV_KEY]: "0" }, + 5000, + ); + expect(result).toBe(5000); + }); +}); + + + +describe("anthropic COT env", () => { + let prev: string | undefined; + + beforeEach(() => { + prev = process.env[COT_ENV_KEY]; + delete process.env[COT_ENV_KEY]; + }); + + afterEach(() => { + if (prev === undefined) { + delete process.env[COT_ENV_KEY]; + } else { + process.env[COT_ENV_KEY] = prev; + } + }); + + describe("parseAnthropicCotBudgetFromEnv", () => { + test("unset and empty → null", () => { + expect(parseAnthropicCotBudgetFromEnv({})).toBeNull(); + expect(parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "" })).toBeNull(); + }); + + test("0 → null", () => { + expect(parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "0" })).toBeNull(); + }); + + test("positive integer → number", () => { + expect(parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "1024" })).toBe(1024); + expect(parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: " 2048 " })).toBe(2048); + }); + + test("invalid non-empty throws", () => { + expect(() => parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "abc" })).toThrow( + /non-negative integer/, + ); + expect(() => parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "12.5" })).toThrow( + /non-negative integer/, + ); + expect(() => parseAnthropicCotBudgetFromEnv({ [COT_ENV_KEY]: "12x" })).toThrow( + /non-negative integer/, + ); + }); + }); + + describe("anthropicThinkingProviderOptions", () => { + test("non-anthropic model → {}", () => { + expect(anthropicThinkingProviderOptions("gpt-4o", {})).toEqual({}); + expect(anthropicThinkingProviderOptions("openai/gpt-4o", { [COT_ENV_KEY]: "1024" })).toEqual( + {}, + ); + }); + + test("anthropic model + budget → thinking enabled", () => { + const env = { [COT_ENV_KEY]: "1024" }; + expect(anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 1024 } }, + }, + }); + expect(anthropicThinkingProviderOptions("claude-sonnet-4-5-20250929", env)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 1024 } }, + }, + }); + }); + + test("anthropic model + no budget → {}", () => { + expect(anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", {})).toEqual({}); + }); + + test("per-agent 0 disables despite env default", () => { + const env = { [COT_ENV_KEY]: "1024" }; + expect(anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env, 0)).toEqual({}); + }); + + test("per-agent budget overrides env default", () => { + const env = { [COT_ENV_KEY]: "1024" }; + expect( + anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env, 2048), + ).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 2048 } }, + }, + }); + }); + + test("ANTHROPIC_COT_BUDGET_MAX clamps per-agent budget", () => { + const env = { [COT_ENV_KEY]: "1024", [COT_MAX_ENV_KEY]: "500" }; + expect( + anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env, 9000), + ).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 500 } }, + }, + }); + }); + + test("ANTHROPIC_COT_BUDGET_MAX clamps env default when no per-agent override", () => { + const env = { [COT_ENV_KEY]: "9000", [COT_MAX_ENV_KEY]: "1000" }; + expect(anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 1000 } }, + }, + }); + }); + }); + + describe("mergeAnthropicCotProviderOptions", () => { + test("aliases mergeProviderOptions(undefined, anthropicThinking…)", () => { + expect(mergeAnthropicCotProviderOptions("openai/gpt-4o", {})).toEqual({}); + const env = { [COT_ENV_KEY]: "1024" }; + expect(mergeAnthropicCotProviderOptions("anthropic/claude-sonnet-4.5", env)).toEqual( + mergeProviderOptions( + undefined, + anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env), + ), + ); + }); + }); + + describe("mergeGatewayGroqPreferenceWithAnthropicCot", () => { + test("gateway order + anthropic when Claude + budget", () => { + const env = { [COT_ENV_KEY]: "1024" }; + expect(mergeGatewayGroqPreferenceWithAnthropicCot("anthropic/claude-sonnet-4.5", env)).toEqual( + mergeProviderOptions( + { providerOptions: { gateway: { order: ["groq"] } } }, + anthropicThinkingProviderOptions("anthropic/claude-sonnet-4.5", env), + ), + ); + }); + }); +}); + +describe("mergeGoogleImageModalitiesWithAnthropicCot", () => { + test("matches explicit google merge + anthropic fragment", () => { + expect(mergeGoogleImageModalitiesWithAnthropicCot("google/gemini-2.5-flash-image", {})).toEqual( + mergeProviderOptions( + { providerOptions: { google: { responseModalities: ["TEXT", "IMAGE"] } } }, + anthropicThinkingProviderOptions("google/gemini-2.5-flash-image", {}), + ), + ); + }); +}); + +describe("parseAnthropicCotBudgetMaxFromEnv", () => { + test("unset → null", () => { + expect(parseAnthropicCotBudgetMaxFromEnv({})).toBeNull(); + }); + + test("positive → cap", () => { + expect(parseAnthropicCotBudgetMaxFromEnv({ [COT_MAX_ENV_KEY]: "8192" })).toBe(8192); + }); +}); + +describe("parseThinkingBudgetFromCharacterSettings", () => { + test("missing or invalid → undefined", () => { + expect(parseThinkingBudgetFromCharacterSettings(undefined)).toBeUndefined(); + expect(parseThinkingBudgetFromCharacterSettings({})).toBeUndefined(); + expect( + parseThinkingBudgetFromCharacterSettings({ + anthropicThinkingBudgetTokens: "nope" as unknown as number, + }), + ).toBeUndefined(); + }); + + test("integer ≥ 0", () => { + expect( + parseThinkingBudgetFromCharacterSettings({ anthropicThinkingBudgetTokens: 0 }), + ).toBe(0); + expect( + parseThinkingBudgetFromCharacterSettings({ anthropicThinkingBudgetTokens: 42 }), + ).toBe(42); + }); + + test("float input is truncated to integer", () => { + expect( + parseThinkingBudgetFromCharacterSettings({ anthropicThinkingBudgetTokens: 4000.9 }), + ).toBe(4000); + expect( + parseThinkingBudgetFromCharacterSettings({ anthropicThinkingBudgetTokens: 1.1 }), + ).toBe(1); + }); +}); + + + +describe("mergeProviderOptions", () => { + test("empty + empty → {}", () => { + expect(mergeProviderOptions(undefined, undefined)).toEqual({}); + }); + + test("preserves google and adds anthropic", () => { + const merged = mergeProviderOptions( + { providerOptions: { google: { responseModalities: ["TEXT", "IMAGE"] } } }, + { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 512 } } } }, + ); + expect(merged).toEqual({ + providerOptions: { + google: { responseModalities: ["TEXT", "IMAGE"] }, + anthropic: { thinking: { type: "enabled", budgetTokens: 512 } }, + }, + }); + }); + + test("merges gateway.order with anthropic", () => { + const merged = mergeProviderOptions( + { providerOptions: { gateway: { order: ["groq"] } } }, + { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1024 } } } }, + ); + expect(merged).toEqual({ + providerOptions: { + gateway: { order: ["groq"] }, + anthropic: { thinking: { type: "enabled", budgetTokens: 1024 } }, + }, + }); + }); + + test("both sides anthropic → later wins shallow fields", () => { + const merged = mergeProviderOptions( + { providerOptions: { anthropic: { sendReasoning: false } } }, + { providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 100 } } } }, + ); + expect(merged.providerOptions.anthropic).toEqual({ + sendReasoning: false, + thinking: { type: "enabled", budgetTokens: 100 }, + }); + }); +}); diff --git a/packages/tests/unit/api/openapi-catalog.test.ts b/packages/tests/unit/api/openapi-catalog.test.ts index a4f5d6683..7dee85ada 100644 --- a/packages/tests/unit/api/openapi-catalog.test.ts +++ b/packages/tests/unit/api/openapi-catalog.test.ts @@ -3,23 +3,25 @@ import { describe, expect, test } from "bun:test"; import { GET, OPTIONS } from "@/app/api/openapi.json/route"; import { discoverPublicApiRoutes } from "@/lib/docs/api-route-discovery"; import { API_ENDPOINTS } from "@/lib/swagger/endpoint-discovery"; -import { jsonRequest } from "./route-test-helpers"; - describe("Public API catalog", () => { - test("route discovery includes every documented endpoint", { timeout: 15_000 }, async () => { - const routes = await discoverPublicApiRoutes(); - const implemented = new Set(); - - for (const route of routes) { - for (const method of route.methods) { - implemented.add(`${method} ${route.path}`); + test( + "route discovery includes every documented endpoint", + async () => { + const routes = await discoverPublicApiRoutes(); + const implemented = new Set(); + + for (const route of routes) { + for (const method of route.methods) { + implemented.add(`${method} ${route.path}`); + } } - } - for (const endpoint of API_ENDPOINTS) { - expect(implemented.has(`${endpoint.method} ${endpoint.path}`)).toBe(true); - } - }); + for (const endpoint of API_ENDPOINTS) { + expect(implemented.has(`${endpoint.method} ${endpoint.path}`)).toBe(true); + } + }, + { timeout: 15_000 }, + ); test("openapi.json includes every documented endpoint and method", async () => { const response = await GET(); @@ -42,9 +44,7 @@ describe("Public API catalog", () => { }); test("openapi.json OPTIONS exposes CORS preflight headers", async () => { - const response = await OPTIONS( - jsonRequest("http://localhost:3000/api/openapi.json", "OPTIONS"), - ); + const response = await OPTIONS(); expect(response.status).toBe(204); expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); diff --git a/packages/tests/unit/api/route-test-helpers.ts b/packages/tests/unit/api/route-test-helpers.ts index 82dea580e..5ebbdd194 100644 --- a/packages/tests/unit/api/route-test-helpers.ts +++ b/packages/tests/unit/api/route-test-helpers.ts @@ -16,7 +16,7 @@ export function jsonRequest( }); } -export function routeParams(params: Record) { +export function routeParams>(params: T): { params: Promise } { return { params: Promise.resolve(params) }; } @@ -34,5 +34,5 @@ export function formDataRequest(url: string, formData: FormData) { export function createFile(name: string, type: string, contents: string | Uint8Array = "test") { const data = typeof contents === "string" ? new TextEncoder().encode(contents) : contents; - return new File([data], name, { type }); + return new File([data as BlobPart], name, { type }); } diff --git a/packages/tests/unit/compat-envelope.test.ts b/packages/tests/unit/compat-envelope.test.ts index cf916a8a4..88ec2389a 100644 --- a/packages/tests/unit/compat-envelope.test.ts +++ b/packages/tests/unit/compat-envelope.test.ts @@ -49,6 +49,7 @@ function makeSandbox(overrides: Partial = {}): MiladySandbox { database_error: null, snapshot_id: null, last_backup_at: null, + last_billed_at: null, last_heartbeat_at: new Date("2026-03-09T12:00:00Z"), error_message: null, error_count: 0, @@ -59,6 +60,11 @@ function makeSandbox(overrides: Partial = {}): MiladySandbox { web_ui_port: 20100, headscale_ip: "100.64.0.5", docker_image: "milady/agent:cloud-full-ui", + billing_status: "active", + hourly_rate: "0.0100", + total_billed: "0.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, created_at: new Date("2026-03-09T10:00:00Z"), updated_at: new Date("2026-03-09T11:00:00Z"), ...overrides, diff --git a/packages/tests/unit/database-url.test.ts b/packages/tests/unit/database-url.test.ts index 80c6d3115..6e5c80d7b 100644 --- a/packages/tests/unit/database-url.test.ts +++ b/packages/tests/unit/database-url.test.ts @@ -6,6 +6,8 @@ import { resolveDatabaseUrl, } from "@/db/database-url"; +type StringEnv = Record; + const originalEnv = { ...process.env }; afterEach(() => { @@ -97,20 +99,20 @@ describe("database URL fallback", () => { test("hydrates process.env when fallback is applied", () => { delete process.env.DATABASE_URL; delete process.env.TEST_DATABASE_URL; - process.env.NODE_ENV = "test"; + (process.env as StringEnv).NODE_ENV = "test"; process.env.LOCAL_DOCKER_DB_HOST = "docker.test"; process.env.LOCAL_DOCKER_DB_PORT = "5439"; delete process.env.CI; delete process.env.VERCEL; delete process.env.DISABLE_LOCAL_DOCKER_DB_FALLBACK; - const applied = applyDatabaseUrlFallback(process.env); + const applied = applyDatabaseUrlFallback(process.env as StringEnv); expect(applied).toBe("postgresql://eliza_dev:local_dev_password@docker.test:5439/eliza_dev"); - expect(process.env.DATABASE_URL).toBe( + expect((process.env as StringEnv).DATABASE_URL).toBe( "postgresql://eliza_dev:local_dev_password@docker.test:5439/eliza_dev", ); - expect(process.env.TEST_DATABASE_URL).toBe( + expect((process.env as StringEnv).TEST_DATABASE_URL).toBe( "postgresql://eliza_dev:local_dev_password@docker.test:5439/eliza_dev", ); }); diff --git a/packages/tests/unit/docker-ssh-cloud-deploy.test.ts b/packages/tests/unit/docker-ssh-cloud-deploy.test.ts index 175bb7567..9eefe7949 100644 --- a/packages/tests/unit/docker-ssh-cloud-deploy.test.ts +++ b/packages/tests/unit/docker-ssh-cloud-deploy.test.ts @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; // which poisons the module cache for later dynamic `await import(...)` calls. // Importing via the file-system path with a cache-buster query param // guarantees we always get the real implementation regardless of mocks. -import { redact } from "../../lib/utils/logger.ts?_real"; +import { redact } from "../../lib/utils/logger?v=docker-ssh-test"; // --------------------------------------------------------------------------- // Env helpers — save/restore to avoid cross-test pollution diff --git a/packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts b/packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts index b36314b94..0132f770f 100644 --- a/packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts +++ b/packages/tests/unit/eliza-app/telegram-ux-helpers.test.ts @@ -302,7 +302,7 @@ describe("Markdown fallback with real HTTP server", () => { attemptCount++; requestLog.push({ body, attempt: attemptCount }); return await handler(body, attemptCount); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; } async function sendWithFallback( diff --git a/packages/tests/unit/eliza-app/whatsapp-auth.test.ts b/packages/tests/unit/eliza-app/whatsapp-auth.test.ts index d68c05eeb..de5c6a7af 100644 --- a/packages/tests/unit/eliza-app/whatsapp-auth.test.ts +++ b/packages/tests/unit/eliza-app/whatsapp-auth.test.ts @@ -83,7 +83,7 @@ describe("WhatsApp Webhook Subscription Verification Logic", () => { }); test("rejects wrong mode", () => { - const mode = "unsubscribe"; + const mode = "unsubscribe" as string; const verifyToken = TEST_VERIFY_TOKEN; const challenge = "1234567890"; @@ -93,7 +93,7 @@ describe("WhatsApp Webhook Subscription Verification Logic", () => { test("rejects wrong verify token", () => { const mode = "subscribe"; - const verifyToken = "wrong_token"; + const verifyToken = "wrong_token" as string; const challenge = "1234567890"; const isValid = mode === "subscribe" && verifyToken === TEST_VERIFY_TOKEN && !!challenge; diff --git a/packages/tests/unit/engagement-metrics/admin-metrics-api.test.ts b/packages/tests/unit/engagement-metrics/admin-metrics-api.test.ts index 76812b2ab..7c8a1bc6a 100644 --- a/packages/tests/unit/engagement-metrics/admin-metrics-api.test.ts +++ b/packages/tests/unit/engagement-metrics/admin-metrics-api.test.ts @@ -40,7 +40,7 @@ const mockOAuthRate = { }; const mockGetMetricsOverview = mock(() => Promise.resolve(mockOverview)); -const mockGetDailyMetrics = mock(() => Promise.resolve(mockDailyMetrics)); +const mockGetDailyMetrics = mock((_start: Date, _end: Date) => Promise.resolve(mockDailyMetrics)); const mockGetRetentionCohorts = mock(() => Promise.resolve(mockRetentionCohorts)); const mockGetActiveUsers = mock(() => Promise.resolve(mockActiveUsers)); const mockGetNewSignups = mock(() => Promise.resolve(mockSignups)); diff --git a/packages/tests/unit/engagement-metrics/compute-metrics-cron.test.ts b/packages/tests/unit/engagement-metrics/compute-metrics-cron.test.ts index 42602cc03..ec5d22d66 100644 --- a/packages/tests/unit/engagement-metrics/compute-metrics-cron.test.ts +++ b/packages/tests/unit/engagement-metrics/compute-metrics-cron.test.ts @@ -12,8 +12,8 @@ import { NextRequest } from "next/server"; // ─── Mock Setup ────────────────────────────────────────────────────────────── -const mockComputeDailyMetrics = mock(() => Promise.resolve()); -const mockComputeRetentionCohorts = mock(() => Promise.resolve()); +const mockComputeDailyMetrics = mock((_date: Date) => Promise.resolve()); +const mockComputeRetentionCohorts = mock((_date: Date) => Promise.resolve()); mock.module("@/lib/services/user-metrics", () => ({ userMetricsService: { diff --git a/packages/tests/unit/evm-rpc-proxy-route.test.ts b/packages/tests/unit/evm-rpc-proxy-route.test.ts index 46538a8fc..c76e5c341 100644 --- a/packages/tests/unit/evm-rpc-proxy-route.test.ts +++ b/packages/tests/unit/evm-rpc-proxy-route.test.ts @@ -13,7 +13,8 @@ const originalFetch = globalThis.fetch; process.env.ALCHEMY_API_KEY = "test-alchemy-key"; const fetchMock = mock(); -globalThis.fetch = fetchMock as typeof fetch; +// Note: fetchMock mimics fetch behavior for isolation in unit tests without external calls. +globalThis.fetch = fetchMock as unknown as typeof fetch; mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -44,7 +45,7 @@ import { POST } from "@/app/api/v1/proxy/evm-rpc/[chain]/route"; describe("EVM RPC proxy route", () => { beforeEach(() => { - globalThis.fetch = fetchMock as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; mockRequireAuthOrApiKeyWithOrg.mockReset(); mockDeductCredits.mockReset(); diff --git a/packages/tests/unit/field-encryption.test.ts b/packages/tests/unit/field-encryption.test.ts index 4c1d1c1f3..86f044cba 100644 --- a/packages/tests/unit/field-encryption.test.ts +++ b/packages/tests/unit/field-encryption.test.ts @@ -1,17 +1,30 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import crypto from "crypto"; -const dbReadFindFirst = mock(async () => null); -const dbWriteFindFirst = mock(async () => null); -const insertReturning = mock(async () => []); -const loggerWarn = mock(() => undefined); -const loggerInfo = mock(() => undefined); -const loggerError = mock(() => undefined); +type OrgEncryptionKeyRow = { + id: string; + organization_id: string; + encrypted_dek: string; + key_version: number; + rotated_at: null; +}; + +type FindFirstOptions = { + where?: Record; + orderBy?: Record; +}; + +const dbReadFindFirst = mock(async (_opts: FindFirstOptions): Promise => null); +const dbWriteFindFirst = mock(async (_opts: FindFirstOptions): Promise => null); +const insertReturning = mock(async (): Promise => []); +const loggerWarn = mock((..._args: string[]) => undefined); +const loggerInfo = mock((..._args: string[]) => undefined); +const loggerError = mock((..._args: string[]) => undefined); const mockDbRead = { query: { organizationEncryptionKeys: { - findFirst: (...args: unknown[]) => dbReadFindFirst(...args), + findFirst: (opts: FindFirstOptions) => dbReadFindFirst(opts), }, }, }; @@ -19,7 +32,7 @@ const mockDbRead = { const mockDbWrite = { query: { organizationEncryptionKeys: { - findFirst: (...args: unknown[]) => dbWriteFindFirst(...args), + findFirst: (opts: FindFirstOptions) => dbWriteFindFirst(opts), }, }, insert: () => ({ @@ -43,9 +56,15 @@ mock.module("@/db/helpers", () => ({ mock.module("@/lib/utils/logger", () => ({ logger: { - warn: (...args: unknown[]) => loggerWarn(...args), - info: (...args: unknown[]) => loggerInfo(...args), - error: (...args: unknown[]) => loggerError(...args), + warn: (...args: string[]) => { + loggerWarn(...args); + }, + info: (...args: string[]) => { + loggerInfo(...args); + }, + error: (...args: string[]) => { + loggerError(...args); + }, }, })); @@ -62,7 +81,7 @@ function wrapDekForTest(dek: Buffer, masterKeyHex: string): string { ); } -function createOrgKey(overrides: Partial> = {}) { +function createOrgKey(overrides: Partial = {}): OrgEncryptionKeyRow { return { id: "key-1", organization_id: "org-1", diff --git a/packages/tests/unit/internal-jwt-auth.test.ts b/packages/tests/unit/internal-jwt-auth.test.ts index 6538387e9..b64834d49 100644 --- a/packages/tests/unit/internal-jwt-auth.test.ts +++ b/packages/tests/unit/internal-jwt-auth.test.ts @@ -38,7 +38,7 @@ describe("Internal JWT Authentication", () => { process.env.JWT_SIGNING_PRIVATE_KEY = TEST_PRIVATE_KEY_B64; process.env.JWT_SIGNING_PUBLIC_KEY = TEST_PUBLIC_KEY_B64; process.env.JWT_SIGNING_KEY_ID = "test-key-id"; - process.env.NODE_ENV = "test"; + (process.env as Record).NODE_ENV = "test"; }); afterAll(() => { @@ -232,7 +232,7 @@ describe("Internal API Middleware", () => { process.env.JWT_SIGNING_PRIVATE_KEY = TEST_PRIVATE_KEY_B64; process.env.JWT_SIGNING_PUBLIC_KEY = TEST_PUBLIC_KEY_B64; process.env.JWT_SIGNING_KEY_ID = "test-key-id"; - process.env.NODE_ENV = "test"; + (process.env as Record).NODE_ENV = "test"; }); afterAll(() => { diff --git a/packages/tests/unit/mcp-google-tools.test.ts b/packages/tests/unit/mcp-google-tools.test.ts index f251bf864..09f678b38 100644 --- a/packages/tests/unit/mcp-google-tools.test.ts +++ b/packages/tests/unit/mcp-google-tools.test.ts @@ -7,6 +7,24 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { authContextStorage } from "@/app/api/mcp/lib/context"; +import type { ListConnectionsParams, OAuthConnection } from "@/lib/services/oauth/types"; + +function googleOAuthFixture( + o: Partial & Pick, +): OAuthConnection { + const { id, status, ...rest } = o; + return { + platform: "google", + platformUserId: rest.platformUserId ?? `pu-${id}`, + scopes: rest.scopes ?? [], + linkedAt: rest.linkedAt ?? new Date("2026-01-01T00:00:00Z"), + tokenExpired: rest.tokenExpired ?? false, + source: rest.source ?? "platform_credentials", + id, + status, + ...rest, + }; +} // ── Mock fetch ────────────────────────────────────────────────────────────── @@ -31,7 +49,7 @@ function setupMockFetch() { status: 404, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; } function resetMockFetch() { @@ -43,14 +61,13 @@ function resetMockFetch() { const mockOAuth = { getValidTokenByPlatform: mock(async () => ({ accessToken: "test-token" })), - listConnections: mock(async () => [ - { + listConnections: mock(async (_params?: ListConnectionsParams) => [ + googleOAuthFixture({ id: "c1", status: "active", email: "user@test.com", scopes: ["gmail.send", "calendar.events"], - linkedAt: "2026-01-01T00:00:00Z", - }, + }), ]), }; @@ -70,7 +87,16 @@ function auth(orgId = "org-1") { } as any; } -async function callTool(name: string, args: Record = {}, orgId = "org-1") { +type GoogleToolHandlerResult = { + content: Array<{ text: string }>; + isError?: boolean; +}; + +async function callTool( + name: string, + args: Record = {}, + orgId = "org-1", +): Promise { const { registerGoogleTools } = await import("@/app/api/mcp/tools/google"); let handler: AnyFn | undefined; const mockServer = { @@ -81,10 +107,10 @@ async function callTool(name: string, args: Record = {}, orgId registerGoogleTools(mockServer); if (!handler) throw new Error(`Tool "${name}" not found`); const h = handler; - return authContextStorage.run(auth(orgId), () => h(args)); + return authContextStorage.run(auth(orgId), () => h(args)) as Promise; } -function parse(result: { content: Array<{ text: string }> }) { +function parse(result: GoogleToolHandlerResult) { return JSON.parse(result.content[0].text); } @@ -98,14 +124,13 @@ describe("Google MCP Tools", () => { accessToken: "test-token", })); mockOAuth.listConnections.mockReset(); - mockOAuth.listConnections.mockImplementation(async () => [ - { + mockOAuth.listConnections.mockImplementation(async (_params?: ListConnectionsParams) => [ + googleOAuthFixture({ id: "c1", status: "active", email: "user@test.com", scopes: ["gmail.send", "calendar.events"], - linkedAt: "2026-01-01T00:00:00Z", - }, + }), ]); }); @@ -156,7 +181,7 @@ describe("Google MCP Tools", () => { expect(p.connected).toBe(true); expect(p.email).toBe("user@test.com"); expect(p.scopes).toContain("gmail.send"); - expect(p.linkedAt).toBe("2026-01-01T00:00:00Z"); + expect(p.linkedAt).toBe("2026-01-01T00:00:00.000Z"); }); test("returns connected=false when no active connection", async () => { @@ -168,8 +193,8 @@ describe("Google MCP Tools", () => { test("filters out revoked/expired connections", async () => { mockOAuth.listConnections.mockImplementation(async () => [ - { id: "c1", status: "revoked", email: "old@test.com" }, - { id: "c2", status: "expired", email: "expired@test.com" }, + googleOAuthFixture({ id: "c1", status: "revoked", email: "old@test.com" }), + googleOAuthFixture({ id: "c2", status: "expired", email: "expired@test.com" }), ]); const p = parse(await callTool("google_status")); expect(p.connected).toBe(false); @@ -214,7 +239,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "to@example.com", @@ -239,7 +264,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "to@example.com", @@ -319,7 +344,7 @@ describe("Google MCP Tools", () => { ); } return new Response("{}", { status: 404 }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_list")); expect(p.resultCount).toBe(1); @@ -338,7 +363,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { pageToken: "next-page-token" }); expect(capturedUrl).toContain("pageToken=next-page-token"); @@ -352,7 +377,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { after: "2026-02-13T00:00:00Z", @@ -371,7 +396,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { query: "from:boss@company.com", @@ -409,7 +434,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_list")); expect(p.resultCount).toBe(2); @@ -424,7 +449,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { maxResults: 25 }); expect(capturedUrl).toContain("maxResults=25"); @@ -438,7 +463,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { labelIds: "INBOX,UNREAD" }); expect(capturedUrl).toContain("labelIds=INBOX"); @@ -600,7 +625,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { timeMin: "2026-01-01T00:00:00Z", @@ -619,7 +644,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events"); expect(capturedUrl).toContain("timeMin="); @@ -633,7 +658,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { timeMax: "2026-03-01T00:00:00Z", @@ -651,7 +676,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { pageToken: "cal-page-2" }); expect(capturedUrl).toContain("pageToken=cal-page-2"); @@ -716,7 +741,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { query: "standup" }); expect(capturedUrl).toContain("q=standup"); @@ -759,7 +784,7 @@ describe("Google MCP Tools", () => { headers: { "Content-Type": "application/json" }, }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_create_event", { summary: "Sync", @@ -783,7 +808,7 @@ describe("Google MCP Tools", () => { headers: { "Content-Type": "application/json" }, }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_create_event", { summary: "Meeting", @@ -826,7 +851,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse( await callTool("calendar_update_event", { @@ -846,7 +871,7 @@ describe("Google MCP Tools", () => { status: 404, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await callTool("calendar_update_event", { eventId: "nonexistent" }); expect(r.isError).toBe(true); @@ -863,7 +888,7 @@ describe("Google MCP Tools", () => { return new Response("", { status: 204 }); } return new Response("", { status: 404 }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("calendar_delete_event", { eventId: "evt-to-delete" })); expect(p.success).toBe(true); @@ -919,7 +944,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("contacts_list", { query: "John" }); expect(capturedUrl).toContain("searchContacts"); @@ -934,7 +959,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("contacts_list", { pageToken: "ct-page-2" }); expect(capturedUrl).toContain("pageToken=ct-page-2"); @@ -1002,7 +1027,7 @@ describe("Google MCP Tools", () => { status: 500, statusText: "Internal Server Error", }); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await callTool("gmail_list"); expect(r.isError).toBe(true); @@ -1021,7 +1046,7 @@ describe("Google MCP Tools", () => { test("handles network timeout", async () => { globalThis.fetch = mock(async () => { throw new Error("Network request failed"); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await callTool("gmail_list"); expect(r.isError).toBe(true); expect(parse(r).error).toContain("Network request failed"); @@ -1033,16 +1058,20 @@ describe("Google MCP Tools", () => { describe("Concurrent request isolation", () => { test("handles concurrent requests with different orgs", async () => { const orgRequests: string[] = []; - mockOAuth.listConnections.mockImplementation(async ({ organizationId }) => { + mockOAuth.listConnections.mockImplementation(async (params?: ListConnectionsParams) => { + const organizationId = params?.organizationId; + if (organizationId === undefined) { + throw new Error("listConnections mock: organizationId required"); + } orgRequests.push(organizationId); await new Promise((r) => setTimeout(r, Math.random() * 50)); return [ - { + googleOAuthFixture({ id: `conn-${organizationId}`, status: "active", email: `${organizationId}@test.com`, scopes: [], - }, + }), ]; }); @@ -1072,7 +1101,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "safe@example.com", @@ -1095,7 +1124,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "to@example.com", @@ -1124,7 +1153,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_read", { messageId: "msg+special/chars" }); expect(capturedUrl).toContain("msg%2Bspecial%2Fchars"); @@ -1152,7 +1181,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list"); const metadataUrl = capturedUrls.find((u) => u.includes("id%2Bwith%2Fslash")); @@ -1184,7 +1213,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await callTool("gmail_list", { after: "2026-02-13T00:00:00Z", @@ -1204,7 +1233,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse( await callTool("contacts_list", { @@ -1233,7 +1262,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("contacts_list", { query: "John" })); expect(p.note).toBeUndefined(); @@ -1264,7 +1293,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_list", { maxResults: 1 })); expect(listUrl).toContain("maxResults=1"); @@ -1294,7 +1323,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { maxResults: 250 }); expect(capturedUrl).toContain("maxResults=250"); @@ -1308,7 +1337,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "test@example.com", @@ -1336,7 +1365,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_read", { messageId: "msg-meta", format: "metadata" })); expect(capturedUrl).toContain("format=metadata"); @@ -1356,7 +1385,7 @@ describe("Google MCP Tools", () => { headers: { "Content-Type": "application/json" }, }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_create_event", { summary: "Minimal", @@ -1395,7 +1424,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_update_event", { eventId: "evt-allday", @@ -1417,7 +1446,7 @@ describe("Google MCP Tools", () => { return new Response("", { status: 204 }); } return new Response("", { status: 404 }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_delete_event", { eventId: "evt-1", @@ -1437,7 +1466,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { after: "2025-12-31T23:59:59Z" }); const decodedUrl = decodeURIComponent(capturedUrl); @@ -1452,7 +1481,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("contacts_list", { pageSize: 100 }); expect(capturedUrl).toContain("pageSize=100"); @@ -1477,7 +1506,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_list")); expect(p.messages[0].id).toBe("msg-nodate"); @@ -1538,7 +1567,7 @@ describe("Google MCP Tools", () => { status: 410, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("gmail_list")); expect(p.resultCount).toBe(0); @@ -1572,7 +1601,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse( await callTool("calendar_update_event", { @@ -1601,7 +1630,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("contacts_list", { query: "Search" })); expect(p.resultCount).toBe(1); @@ -1618,7 +1647,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_list_events", { calendarId: "team@group.calendar.google.com", @@ -1631,7 +1660,7 @@ describe("Google MCP Tools", () => { test("googleFetch treats 204 as success (not an error)", async () => { globalThis.fetch = mock(async () => { return new Response("", { status: 204 }); - }) as typeof fetch; + }) as unknown as typeof fetch; const p = parse(await callTool("calendar_delete_event", { eventId: "evt-204" })); expect(p.success).toBe(true); @@ -1668,7 +1697,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list", { query: "is:unread from:ceo@acme.com", @@ -1718,7 +1747,7 @@ describe("Google MCP Tools", () => { headers: { "Content-Type": "application/json" }, }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("calendar_create_event", { summary: "Team Sync", @@ -1755,7 +1784,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; const r = await callTool("calendar_list_events", { timeMin: "2026-01-01T00:00:00Z", @@ -1864,7 +1893,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_send", { to: "test@example.com", @@ -1890,7 +1919,7 @@ describe("Google MCP Tools", () => { status: 200, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; await callTool("gmail_list"); expect(receivedSignal).not.toBeNull(); @@ -1901,7 +1930,7 @@ describe("Google MCP Tools", () => { globalThis.fetch = mock(async () => { const err = new DOMException("The operation was aborted.", "AbortError"); throw err; - }) as typeof fetch; + }) as unknown as typeof fetch; const result = parse(await callTool("gmail_list")); expect(result.error).toContain("timed out"); @@ -1911,7 +1940,7 @@ describe("Google MCP Tools", () => { test("non-abort fetch errors propagate normally", async () => { globalThis.fetch = mock(async () => { throw new TypeError("fetch failed"); - }) as typeof fetch; + }) as unknown as typeof fetch; const result = parse(await callTool("gmail_list")); expect(result.error).toContain("fetch failed"); @@ -1944,7 +1973,7 @@ describe("Google MCP Tools", () => { }), { status: 200, headers: { "Content-Type": "application/json" } }, ); - }) as typeof fetch; + }) as unknown as typeof fetch; const listResult = parse(await callTool("gmail_list")); const msgId = listResult.messages[0].id; @@ -1974,7 +2003,7 @@ describe("Google MCP Tools", () => { return new Response("", { status: 204 }); } return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }); - }) as typeof fetch; + }) as unknown as typeof fetch; const createResult = parse( await callTool("calendar_create_event", { diff --git a/packages/tests/unit/mcp-hubspot-tools.test.ts b/packages/tests/unit/mcp-hubspot-tools.test.ts index 4266d2543..b8c912c1d 100644 --- a/packages/tests/unit/mcp-hubspot-tools.test.ts +++ b/packages/tests/unit/mcp-hubspot-tools.test.ts @@ -11,6 +11,37 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { authContextStorage } from "@/app/api/mcp/lib/context"; +import type { + GetTokenByPlatformParams, + ListConnectionsParams, + OAuthConnection, + TokenResult, +} from "@/lib/services/oauth/types"; + +function mockHubspotToken(): TokenResult { + return { + accessToken: "mock-hubspot-token", + refreshed: false, + fromCache: false, + }; +} + +function hubspotOAuthFixture( + o: Partial & Pick, +): OAuthConnection { + const { id, status, ...rest } = o; + return { + platform: "hubspot", + platformUserId: rest.platformUserId ?? `pu-${id}`, + scopes: rest.scopes ?? [], + linkedAt: rest.linkedAt ?? new Date("2024-01-15T10:00:00Z"), + tokenExpired: rest.tokenExpired ?? false, + source: rest.source ?? "platform_credentials", + id, + status, + ...rest, + }; +} // Mock fetch globally for API tests const originalFetch = globalThis.fetch; @@ -35,7 +66,7 @@ function setupMockFetch() { status: 404, headers: { "Content-Type": "application/json" }, }); - }) as typeof fetch; + }) as unknown as typeof fetch; } function resetMockFetch() { @@ -46,20 +77,15 @@ function resetMockFetch() { // Mock OAuth service const mockOAuthService = { getValidTokenByPlatform: mock( - async ({ organizationId, platform }: { organizationId: string; platform: string }) => { - if (platform !== "hubspot") { - throw new Error(`Unknown platform: ${platform}`); + async (params: GetTokenByPlatformParams): Promise => { + if (params.platform !== "hubspot") { + throw new Error(`Unknown platform: ${params.platform}`); } - // By default, throw "not connected" - tests can override throw new Error("No active connection found for hubspot"); }, ), - listConnections: mock( - async ({ organizationId, platform }: { organizationId: string; platform?: string }) => { - return []; - }, - ), - isPlatformConnected: mock(async (organizationId: string, platform: string) => { + listConnections: mock(async (_params: ListConnectionsParams): Promise => []), + isPlatformConnected: mock(async (_organizationId: string, _platform: string): Promise => { return false; }), }; @@ -164,13 +190,13 @@ describe("HubSpot MCP Tools", () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); mockOAuthService.listConnections.mockImplementation(async () => [ - { + hubspotOAuthFixture({ id: "conn-123", status: "active", email: "user@example.com", scopes: ["crm.objects.contacts.read", "crm.objects.contacts.write"], - linkedAt: "2024-01-15T10:00:00Z", - }, + linkedAt: new Date("2024-01-15T10:00:00Z"), + }), ]); let handler: any; @@ -219,8 +245,8 @@ describe("HubSpot MCP Tools", () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); mockOAuthService.listConnections.mockImplementation(async () => [ - { id: "conn-1", status: "revoked", email: "old@example.com" }, - { id: "conn-2", status: "expired", email: "expired@example.com" }, + hubspotOAuthFixture({ id: "conn-1", status: "revoked", email: "old@example.com" }), + hubspotOAuthFixture({ id: "conn-2", status: "expired", email: "expired@example.com" }), ]); let handler: any; @@ -272,9 +298,7 @@ describe("HubSpot MCP Tools", () => { test("lists contacts successfully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/objects/contacts", { status: 200, @@ -320,9 +344,7 @@ describe("HubSpot MCP Tools", () => { test("creates contact successfully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/objects/contacts", { status: 201, @@ -357,9 +379,7 @@ describe("HubSpot MCP Tools", () => { test("handles HubSpot API errors", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/objects/contacts", { status: 409, @@ -392,9 +412,7 @@ describe("HubSpot MCP Tools", () => { test("lists companies successfully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/objects/companies", { status: 200, @@ -438,9 +456,7 @@ describe("HubSpot MCP Tools", () => { test("lists deals successfully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/objects/deals", { status: 200, @@ -484,9 +500,7 @@ describe("HubSpot MCP Tools", () => { test("lists owners successfully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set("api.hubapi.com/crm/v3/owners", { status: 200, @@ -532,9 +546,7 @@ describe("HubSpot MCP Tools", () => { test("creates association between contact and company", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); mockFetchResponses.set( "api.hubapi.com/crm/v3/objects/contacts/101/associations/companies/301/1", @@ -575,13 +587,11 @@ describe("HubSpot MCP Tools", () => { test("handles network timeout gracefully", async () => { const { registerHubSpotTools } = await import("@/app/api/mcp/tools/hubspot"); - mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => ({ - accessToken: "mock-hubspot-token", - })); + mockOAuthService.getValidTokenByPlatform.mockImplementation(async () => mockHubspotToken()); globalThis.fetch = mock(async () => { throw new Error("Network request failed"); - }) as typeof fetch; + }) as unknown as typeof fetch; let handler: any; const mockServer = { @@ -605,16 +615,17 @@ describe("HubSpot MCP Tools", () => { const orgRequests: string[] = []; - mockOAuthService.listConnections.mockImplementation(async ({ organizationId }) => { + mockOAuthService.listConnections.mockImplementation(async (params: ListConnectionsParams) => { + const { organizationId } = params; orgRequests.push(organizationId); await new Promise((r) => setTimeout(r, Math.random() * 50)); return [ - { + hubspotOAuthFixture({ id: `conn-${organizationId}`, status: "active", email: `user-${organizationId}@example.com`, scopes: [], - }, + }), ]; }); diff --git a/packages/tests/unit/mcp-lib.test.ts b/packages/tests/unit/mcp-lib.test.ts index d31f99761..10c58034d 100644 --- a/packages/tests/unit/mcp-lib.test.ts +++ b/packages/tests/unit/mcp-lib.test.ts @@ -4,6 +4,7 @@ */ import { describe, expect, test } from "bun:test"; +import type { AuthResultWithOrg } from "@/app/api/mcp/lib/context"; import { authContextStorage, getAuthContext } from "@/app/api/mcp/lib/context"; import { errorResponse, jsonResponse } from "@/app/api/mcp/lib/responses"; @@ -84,9 +85,10 @@ describe("authContextStorage", () => { organization_id: "org-456", organization: { id: "org-456", name: "Test Org" }, }, - } as any; + authMethod: "session" as const, + } as AuthResultWithOrg; - let capturedContext: any; + let capturedContext: AuthResultWithOrg | undefined; await authContextStorage.run(mockAuth, async () => { capturedContext = authContextStorage.getStore(); }); @@ -95,18 +97,34 @@ describe("authContextStorage", () => { }); test("context is isolated between runs", async () => { - const auth1 = { user: { id: "user-1" } } as any; - const auth2 = { user: { id: "user-2" } } as any; + const auth1 = { + user: { + id: "user-1", + organization_id: "org-1", + organization: { id: "org-1", name: "O1" }, + }, + authMethod: "session" as const, + } as AuthResultWithOrg; + const auth2 = { + user: { + id: "user-2", + organization_id: "org-2", + organization: { id: "org-2", name: "O2" }, + }, + authMethod: "session" as const, + } as AuthResultWithOrg; const results: string[] = []; await Promise.all([ authContextStorage.run(auth1, async () => { await new Promise((r) => setTimeout(r, 10)); - results.push(authContextStorage.getStore()?.user.id); + const id = authContextStorage.getStore()?.user.id; + if (id !== undefined) results.push(id); }), authContextStorage.run(auth2, async () => { - results.push(authContextStorage.getStore()?.user.id); + const id = authContextStorage.getStore()?.user.id; + if (id !== undefined) results.push(id); }), ]); @@ -127,13 +145,14 @@ describe("getAuthContext", () => { organization_id: "test-org", organization: { id: "test-org", name: "Test" }, }, - } as any; + authMethod: "session" as const, + } as AuthResultWithOrg; - let result: any; + let result: AuthResultWithOrg | undefined; await authContextStorage.run(mockAuth, async () => { result = getAuthContext(); }); - expect(result.user.id).toBe("test-user"); + expect(result?.user.id).toBe("test-user"); }); }); diff --git a/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts b/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts index d95e619be..0fe1efe46 100644 --- a/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts +++ b/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts @@ -114,7 +114,7 @@ describe("MCP proxy affiliate pricing", () => { status: 200, headers: { "Content-Type": "application/json" }, }), - ) as typeof globalThis.fetch; + ) as unknown as typeof globalThis.fetch; }); afterEach(() => { @@ -177,7 +177,7 @@ describe("MCP proxy affiliate pricing", () => { new Response("upstream failure", { status: 502, }), - ) as typeof globalThis.fetch; + ) as unknown as typeof globalThis.fetch; const request = new NextRequest("https://example.com/api/mcp/proxy/mcp-1", { method: "POST", diff --git a/packages/tests/unit/mcp-twitter-tools.test.ts b/packages/tests/unit/mcp-twitter-tools.test.ts index fa08ffc3d..cc7131375 100644 --- a/packages/tests/unit/mcp-twitter-tools.test.ts +++ b/packages/tests/unit/mcp-twitter-tools.test.ts @@ -7,6 +7,24 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { authContextStorage } from "@/app/api/mcp/lib/context"; +import type { OAuthConnection } from "@/lib/services/oauth/types"; + +function twitterOAuthFixture( + o: Partial & Pick, +): OAuthConnection { + const { id, status, ...rest } = o; + return { + platform: "twitter", + platformUserId: rest.platformUserId ?? `pu-${id}`, + scopes: rest.scopes ?? [], + linkedAt: rest.linkedAt ?? new Date("2026-01-01T00:00:00Z"), + tokenExpired: rest.tokenExpired ?? false, + source: rest.source ?? "platform_credentials", + id, + status, + ...rest, + }; +} process.env.TWITTER_API_KEY = "test-api-key"; process.env.TWITTER_API_SECRET_KEY = "test-api-secret"; @@ -193,13 +211,12 @@ const mockOAuth = { accessTokenSecret: "sec", })), listConnections: mock(async () => [ - { + twitterOAuthFixture({ id: "c1", status: "active", displayName: "testuser", scopes: ["read", "write"], - linkedAt: "2026-01-01T00:00:00Z", - }, + }), ]), }; @@ -219,7 +236,16 @@ function auth(orgId = "org-1") { } as any; } -async function callTool(name: string, args: Record = {}, orgId = "org-1") { +type TwitterToolHandlerResult = { + content: Array<{ text: string }>; + isError?: boolean; +}; + +async function callTool( + name: string, + args: Record = {}, + orgId = "org-1", +): Promise { const { registerTwitterTools } = await import("@/app/api/mcp/tools/twitter"); let handler: AnyFn | undefined; const mockServer = { @@ -230,10 +256,10 @@ async function callTool(name: string, args: Record = {}, orgId registerTwitterTools(mockServer); if (!handler) throw new Error(`Tool "${name}" not found`); const h = handler; - return authContextStorage.run(auth(orgId), () => h(args)); + return authContextStorage.run(auth(orgId), () => h(args)) as Promise; } -function parse(result: { content: Array<{ text: string }> }) { +function parse(result: TwitterToolHandlerResult) { return JSON.parse(result.content[0].text); } @@ -249,13 +275,12 @@ describe("Twitter MCP Tools", () => { })); mockOAuth.listConnections.mockReset(); mockOAuth.listConnections.mockImplementation(async () => [ - { + twitterOAuthFixture({ id: "c1", status: "active", displayName: "testuser", scopes: ["read", "write"], - linkedAt: "2026-01-01T00:00:00Z", - }, + }), ]); }); @@ -327,8 +352,8 @@ describe("Twitter MCP Tools", () => { test("filters out revoked/expired connections", async () => { mockOAuth.listConnections.mockImplementation(async () => [ - { id: "c1", status: "revoked", displayName: "old" }, - { id: "c2", status: "expired", displayName: "expired" }, + twitterOAuthFixture({ id: "c1", status: "revoked", displayName: "old" }), + twitterOAuthFixture({ id: "c2", status: "expired", displayName: "expired" }), ]); const p = parse(await callTool("twitter_status")); expect(p.connected).toBe(false); @@ -969,7 +994,9 @@ describe("Twitter MCP Tools", () => { test("missing accessTokenSecret returns a specific reconnect error", async () => { mockOAuth.getValidTokenByPlatform.mockImplementation(async () => ({ accessToken: "tok", - accessTokenSecret: null, + accessTokenSecret: "", + refreshed: false, + fromCache: false, })); const r = await callTool("twitter_get_me"); expect(r.isError).toBe(true); diff --git a/packages/tests/unit/milady-create-routes.test.ts b/packages/tests/unit/milady-create-routes.test.ts index e15a3ec21..ed8587888 100644 --- a/packages/tests/unit/milady-create-routes.test.ts +++ b/packages/tests/unit/milady-create-routes.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mockMiladyPricingMinimumDepositForRouteTests } from "../helpers/mock-milady-pricing-for-route-tests"; import { jsonRequest } from "./api/route-test-helpers"; const mockRequireAuthOrApiKeyWithOrg = mock(); @@ -54,9 +55,7 @@ mock.module("@/lib/services/milady-billing-gate", () => ({ checkMiladyCreditGate: mockCheckMiladyCreditGate, })); -mock.module("@/lib/constants/milady-pricing", () => ({ - MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, -})); +mockMiladyPricingMinimumDepositForRouteTests(5); mock.module("@/lib/utils/logger", () => ({ logger: { diff --git a/packages/tests/unit/milady-sandbox-service.test.ts b/packages/tests/unit/milady-sandbox-service.test.ts index 4982a702c..46eb95b91 100644 --- a/packages/tests/unit/milady-sandbox-service.test.ts +++ b/packages/tests/unit/milady-sandbox-service.test.ts @@ -72,8 +72,18 @@ vi.mock("@/lib/services/sandbox-provider", () => ({ createSandboxProvider: vi.fn(), })); +import type { SandboxProvider } from "@/lib/services/sandbox-provider"; import { MiladySandboxService } from "@/lib/services/milady-sandbox"; +function testSandboxProvider(overrides: Partial = {}): SandboxProvider { + return { + create: vi.fn(), + stop: vi.fn().mockResolvedValue(undefined), + checkHealth: vi.fn(), + ...overrides, + }; +} + describe("MiladySandboxService lifecycle guards", () => { beforeEach(() => { vi.clearAllMocks(); @@ -83,7 +93,7 @@ describe("MiladySandboxService lifecycle guards", () => { }); afterEach(() => { - vi.restoreAllMocks(); + (vi as unknown as { restoreAllMocks: () => void }).restoreAllMocks(); }); it("stops an orphanable sandbox on delete even when DB status is disconnected", async () => { @@ -115,6 +125,12 @@ describe("MiladySandboxService lifecycle guards", () => { web_ui_port: null, headscale_ip: null, docker_image: null, + billing_status: "active", + last_billed_at: null, + hourly_rate: "0.0100", + total_billed: "0.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, created_at: new Date(), updated_at: new Date(), }; @@ -130,9 +146,7 @@ describe("MiladySandboxService lifecycle guards", () => { fn({ execute }), ); - const provider = { - stop: vi.fn().mockResolvedValue(undefined), - } as unknown as ConstructorParameters[0]; + const provider = testSandboxProvider(); const service = new MiladySandboxService(provider); const result = await service.deleteAgent("agent-1", "org-1"); @@ -170,6 +184,12 @@ describe("MiladySandboxService lifecycle guards", () => { web_ui_port: null, headscale_ip: null, docker_image: null, + billing_status: "active", + last_billed_at: null, + hourly_rate: "0.0100", + total_billed: "0.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, created_at: new Date(), updated_at: new Date(), }; @@ -184,9 +204,7 @@ describe("MiladySandboxService lifecycle guards", () => { fn({ execute }), ); - const provider = { - stop: vi.fn().mockResolvedValue(undefined), - } as unknown as ConstructorParameters[0]; + const provider = testSandboxProvider(); const service = new MiladySandboxService(provider); const result = await service.deleteAgent("agent-1", "org-1"); @@ -227,6 +245,12 @@ describe("MiladySandboxService lifecycle guards", () => { web_ui_port: null, headscale_ip: null, docker_image: null, + billing_status: "active", + last_billed_at: null, + hourly_rate: "0.0100", + total_billed: "0.00", + shutdown_warning_sent_at: null, + scheduled_shutdown_at: null, created_at: new Date(), updated_at: new Date(), }; @@ -242,9 +266,9 @@ describe("MiladySandboxService lifecycle guards", () => { fn({ execute }), ); - const provider = { + const provider = testSandboxProvider({ stop: vi.fn().mockRejectedValue(new Error("Container not found in memory or DB")), - } as unknown as ConstructorParameters[0]; + }); const service = new MiladySandboxService(provider); const result = await service.deleteAgent("agent-1", "org-1"); diff --git a/packages/tests/unit/milaidy-agent-routes-followups.test.ts b/packages/tests/unit/milaidy-agent-routes-followups.test.ts index 9fdeb7084..3b700170b 100644 --- a/packages/tests/unit/milaidy-agent-routes-followups.test.ts +++ b/packages/tests/unit/milaidy-agent-routes-followups.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { mockMiladyPricingMinimumDepositForRouteTests } from "../helpers/mock-milady-pricing-for-route-tests"; import { jsonRequest, routeParams } from "./api/route-test-helpers"; const mockRequireAuthOrApiKeyWithOrg = mock(); @@ -80,9 +81,7 @@ mock.module("@/lib/services/milady-billing-gate", () => ({ checkMiladyCreditGate: mockCheckMiladyCreditGate, })); -mock.module("@/lib/constants/milady-pricing", () => ({ - MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, -})); +mockMiladyPricingMinimumDepositForRouteTests(5); mock.module("@/lib/utils/logger", () => ({ logger: { diff --git a/packages/tests/unit/milaidy-pairing-token-route.test.ts b/packages/tests/unit/milaidy-pairing-token-route.test.ts index b3635f061..873b29633 100644 --- a/packages/tests/unit/milaidy-pairing-token-route.test.ts +++ b/packages/tests/unit/milaidy-pairing-token-route.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; import { routeParams } from "./api/route-test-helpers"; @@ -6,7 +6,8 @@ import { routeParams } from "./api/route-test-helpers"; const mockRequireAuthOrApiKeyWithOrg = mock(); const mockFindByIdAndOrg = mock(); const mockGenerateToken = mock(); -const mockGetMiladyAgentPublicWebUiUrl = mock(); + +const savedAgentBaseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -24,18 +25,15 @@ mock.module("@/lib/services/pairing-token", () => ({ }), })); -mock.module("@/lib/milady-web-ui", () => ({ - getMiladyAgentPublicWebUiUrl: mockGetMiladyAgentPublicWebUiUrl, -})); - import { POST } from "@/app/api/v1/milaidy/agents/[agentId]/pairing-token/route"; describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { beforeEach(() => { + delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + mockRequireAuthOrApiKeyWithOrg.mockReset(); mockFindByIdAndOrg.mockReset(); mockGenerateToken.mockReset(); - mockGetMiladyAgentPublicWebUiUrl.mockReset(); mockRequireAuthOrApiKeyWithOrg.mockResolvedValue({ user: { @@ -45,6 +43,14 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { }); }); + afterEach(() => { + if (savedAgentBaseDomain === undefined) { + delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + } else { + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = savedAgentBaseDomain; + } + }); + test("returns 404 when the agent is not visible in the caller org", async () => { mockFindByIdAndOrg.mockResolvedValue(null); @@ -70,7 +76,6 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { MILADY_API_TOKEN: "ui-token", }, }); - mockGetMiladyAgentPublicWebUiUrl.mockReturnValue("https://agent-1.waifu.fun"); mockGenerateToken.mockResolvedValue("pair-token"); const response = await POST( @@ -106,7 +111,6 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { ELIZA_API_TOKEN: "", }, }); - mockGetMiladyAgentPublicWebUiUrl.mockReturnValue("https://agent-1.waifu.fun"); mockGenerateToken.mockResolvedValue("pair-token"); const response = await POST( diff --git a/packages/tests/unit/milaidy-sandbox-bridge-security.test.ts b/packages/tests/unit/milaidy-sandbox-bridge-security.test.ts index 8dc0a0aca..52b7813a0 100644 --- a/packages/tests/unit/milaidy-sandbox-bridge-security.test.ts +++ b/packages/tests/unit/milaidy-sandbox-bridge-security.test.ts @@ -79,7 +79,7 @@ describe("MiladySandboxService bridge SSRF guards", () => { mockFindDockerNodeById.mockReset(); mockAssertSafeOutboundUrl.mockReset(); fetchMock.mockReset(); - globalThis.fetch = fetchMock as typeof fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; const runningSandbox = { id: "agent-1", @@ -338,11 +338,15 @@ describe("MiladySandboxService bridge SSRF guards", () => { const backup = { id: "backup-1", sandbox_record_id: "agent-1", - snapshot_type: "manual", - state_data: { sessions: ["restored"] }, + snapshot_type: "manual" as const, + state_data: { + memories: [], + config: {}, + workspaceFiles: {}, + }, + vercel_snapshot_id: null, size_bytes: 23, created_at: new Date(), - updated_at: new Date(), }; mockFindRunningSandbox.mockResolvedValue(runningSandbox); diff --git a/packages/tests/unit/oauth/errors.test.ts b/packages/tests/unit/oauth/errors.test.ts index c124e98fd..0b31d2f47 100644 --- a/packages/tests/unit/oauth/errors.test.ts +++ b/packages/tests/unit/oauth/errors.test.ts @@ -331,8 +331,9 @@ describe("OAuthErrorCode Enum", () => { "INTERNAL_ERROR", ]; + const values = Object.values(OAuthErrorCode) as string[]; for (const code of expectedCodes) { - expect(Object.values(OAuthErrorCode)).toContain(code); + expect(values).toContain(code); } }); }); diff --git a/packages/tests/unit/oauth/oauth-service.test.ts b/packages/tests/unit/oauth/oauth-service.test.ts index 5c8181dff..3d13385d9 100644 --- a/packages/tests/unit/oauth/oauth-service.test.ts +++ b/packages/tests/unit/oauth/oauth-service.test.ts @@ -175,7 +175,7 @@ describe("OAuth Service Logic", () => { expect(provider.type).toBe("api_key"); // For api_key platforms, we expect requiresCredentials: true // and authUrl pointing to the initiate route - expect(provider.routes.initiate).toBeDefined(); + expect(provider.routes?.initiate).toBeDefined(); } }); @@ -184,7 +184,7 @@ describe("OAuth Service Logic", () => { const oauthPlatforms = [ { id: "google", type: "oauth2" }, { id: "twitter", type: "oauth1a" }, - ]; + ] as const; for (const { id, type } of oauthPlatforms) { const provider = OAUTH_PROVIDERS[id]; @@ -319,8 +319,9 @@ describe("OAuth Types", () => { // Create connections with each status for (const status of statuses) { - const conn = createMockConnection(status as OAuthConnection["status"]); - expect(conn.status).toBe(status); + const typedStatus = status as OAuthConnection["status"]; + const conn = createMockConnection(typedStatus); + expect(conn.status).toBe(typedStatus); } }); }); diff --git a/packages/tests/unit/oauth/provider-registry.test.ts b/packages/tests/unit/oauth/provider-registry.test.ts index fcf143feb..d5a8879ac 100644 --- a/packages/tests/unit/oauth/provider-registry.test.ts +++ b/packages/tests/unit/oauth/provider-registry.test.ts @@ -127,7 +127,7 @@ describe("Provider Registry", () => { }); it("should have empty callback route (API key platforms)", () => { - expect(twilio.routes.callback).toBe(""); + expect(twilio.routes!.callback).toBe(""); }); }); diff --git a/packages/tests/unit/oauth/token-cache.test.ts b/packages/tests/unit/oauth/token-cache.test.ts index ee1c6a1a7..8dc1d7baa 100644 --- a/packages/tests/unit/oauth/token-cache.test.ts +++ b/packages/tests/unit/oauth/token-cache.test.ts @@ -239,12 +239,15 @@ describe("Token Cache Logic", () => { }); it("should handle undefined expiresAt", () => { - const expiresAt: Date | undefined = undefined; - const result = expiresAt - ? expiresAt instanceof Date - ? expiresAt - : new Date(expiresAt) - : undefined; + const expiresAt = undefined as Date | undefined; + let result: Date | undefined; + if (expiresAt === undefined) { + result = undefined; + } else if (expiresAt instanceof Date) { + result = expiresAt; + } else { + result = new Date(expiresAt); + } expect(result).toBeUndefined(); }); diff --git a/packages/tests/unit/performance-optimizations.test.ts b/packages/tests/unit/performance-optimizations.test.ts index 164453339..7f2ae6a18 100644 --- a/packages/tests/unit/performance-optimizations.test.ts +++ b/packages/tests/unit/performance-optimizations.test.ts @@ -42,6 +42,18 @@ describe("Action validation cache", () => { const emptyState = { values: {}, data: {}, text: "" } as State; + type ProviderSnapshot = { data?: unknown; values?: unknown }; + + function providerActionsData(state: ProviderSnapshot): { name: string }[] { + expect(state.data).toBeDefined(); + return (state.data as { actionsData: { name: string }[] }).actionsData; + } + + function providerValues(state: ProviderSnapshot): Record { + expect(state.values).toBeDefined(); + return state.values as Record; + } + test("returns validated actions, filters out invalid ones", async () => { const validAction: Action = { name: "TEST_ACTION", @@ -66,8 +78,8 @@ describe("Action validation cache", () => { emptyState, ); - expect(result.data.actionsData).toHaveLength(1); - expect(result.data.actionsData[0].name).toBe("TEST_ACTION"); + expect(providerActionsData(result)).toHaveLength(1); + expect(providerActionsData(result)[0].name).toBe("TEST_ACTION"); }); test("caches results — same message ID validates only once", async () => { @@ -165,8 +177,8 @@ describe("Action validation cache", () => { makeMessage("msg-error-1"), emptyState, ); - expect(result.data.actionsData).toHaveLength(1); - expect(result.data.actionsData[0].name).toBe("GOOD"); + expect(providerActionsData(result)).toHaveLength(1); + expect(providerActionsData(result)[0].name).toBe("GOOD"); }); test("caches discoverable tool count from MCP service", async () => { @@ -184,7 +196,7 @@ describe("Action validation cache", () => { makeMessage("msg-mcp-count-1"), emptyState, ); - expect(result.values.discoverableToolCount).toBe("42"); + expect(providerValues(result).discoverableToolCount).toBe("42"); }); test("handles missing MCP service gracefully", async () => { @@ -202,7 +214,7 @@ describe("Action validation cache", () => { } as unknown as IAgentRuntime; const result = await actionsProvider.get!(runtime, makeMessage("msg-no-mcp-1"), emptyState); - expect(result.values.discoverableToolCount).toBe(""); + expect(providerValues(result).discoverableToolCount).toBe(""); }); test("no validated actions returns empty values", async () => { @@ -220,8 +232,8 @@ describe("Action validation cache", () => { makeMessage("msg-empty-1"), emptyState, ); - expect(result.data.actionsData).toHaveLength(0); - expect(result.values.actionsWithParams).toBe(""); + expect(providerActionsData(result)).toHaveLength(0); + expect(providerValues(result).actionsWithParams).toBe(""); }); test("parallel calls after cache is warm all return same data", async () => { @@ -251,8 +263,8 @@ describe("Action validation cache", () => { ]); expect(validateCallCount).toBe(1); - expect(r1.data.actionsData).toEqual(r2.data.actionsData); - expect(r2.data.actionsData).toEqual(r3.data.actionsData); + expect(providerActionsData(r1)).toEqual(providerActionsData(r2)); + expect(providerActionsData(r2)).toEqual(providerActionsData(r3)); }); test("concurrent calls on cold cache cause redundant validation (known trade-off)", async () => { @@ -282,9 +294,9 @@ describe("Action validation cache", () => { // All 3 see !cached and run validation independently — this is the known trade-off. // Correctness is preserved: all return valid data, just redundant work. expect(validateCallCount).toBeGreaterThanOrEqual(2); - expect(r1.data.actionsData).toHaveLength(1); - expect(r2.data.actionsData).toHaveLength(1); - expect(r3.data.actionsData).toHaveLength(1); + expect(providerActionsData(r1)).toHaveLength(1); + expect(providerActionsData(r2)).toHaveLength(1); + expect(providerActionsData(r3)).toHaveLength(1); }); test("invalidation clears the stale eviction timer before recaching", async () => { @@ -355,10 +367,10 @@ describe("Action validation cache", () => { } as unknown as Memory; const r1 = await actionsProvider.get!(runtime, msg, emptyState); - expect(r1.data.actionsData).toHaveLength(1); + expect(providerActionsData(r1)).toHaveLength(1); const r2 = await actionsProvider.get!(runtime, msg, emptyState); - expect(r2.data.actionsData).toHaveLength(1); + expect(providerActionsData(r2)).toHaveLength(1); expect(validateCallCount).toBe(2); }); }); diff --git a/packages/tests/unit/pr385-round5-fixes.test.ts b/packages/tests/unit/pr385-round5-fixes.test.ts index 28661f732..3013c8ad1 100644 --- a/packages/tests/unit/pr385-round5-fixes.test.ts +++ b/packages/tests/unit/pr385-round5-fixes.test.ts @@ -239,7 +239,7 @@ describe("agents/route orphan character cleanup", () => { test("original error propagates even if cleanup fails", async () => { const fakeCharactersService = { - delete: async () => { + delete: async (_id?: string) => { throw new Error("cleanup also failed"); }, }; diff --git a/packages/tests/unit/privy-sync.test.ts b/packages/tests/unit/privy-sync.test.ts index 274120110..04704e867 100644 --- a/packages/tests/unit/privy-sync.test.ts +++ b/packages/tests/unit/privy-sync.test.ts @@ -226,7 +226,7 @@ describe("syncUserFromPrivy", () => { }), ); expect(mockUpsertPrivyIdentity).toHaveBeenCalledWith("user-new", "did:privy:new-user"); - expect(result).toEqual(hydratedUser); + expect(result).toMatchObject(hydratedUser); }); test("upserts user identity before reading linked accounts by new Privy id", async () => { @@ -268,7 +268,7 @@ describe("syncUserFromPrivy", () => { }), ); expect(mockUpsertPrivyIdentity).toHaveBeenCalledWith("user-existing", "did:privy:new-user"); - expect(result).toEqual(linkedUser); + expect(result).toMatchObject(linkedUser); }); test("restores the previous Privy ID when account linking upsert fails", async () => { @@ -492,7 +492,7 @@ describe("syncUserFromPrivy", () => { linkedAccounts: [], } as never); - expect(result).toEqual(recoveredUser); + expect(result).toMatchObject(recoveredUser); expect(mockDeleteUserRecord).not.toHaveBeenCalled(); expect(mockMarkInviteAccepted).toHaveBeenCalledWith("invite-1", "user-invite"); }); @@ -576,7 +576,7 @@ describe("syncUserFromPrivy", () => { linkedAccounts: [], } as never); - expect(result).toEqual(recoveredUser); + expect(result).toMatchObject(recoveredUser); expect(mockDeleteUserRecord).not.toHaveBeenCalled(); expect(mockDeleteOrganization).not.toHaveBeenCalled(); }); diff --git a/packages/tests/unit/provisioning-jobs-followups.test.ts b/packages/tests/unit/provisioning-jobs-followups.test.ts index bf3ab3678..7671789bc 100644 --- a/packages/tests/unit/provisioning-jobs-followups.test.ts +++ b/packages/tests/unit/provisioning-jobs-followups.test.ts @@ -137,7 +137,7 @@ describe("ProvisioningJobService follow-ups", () => { expectedUpdatedAt: new Date("2026-03-01T12:00:00.000Z"), }); - expect(result).toEqual({ + expect(result).toMatchObject({ created: false, job: { id: "job-existing", diff --git a/packages/tests/unit/provisioning-jobs.test.ts b/packages/tests/unit/provisioning-jobs.test.ts index 8cef304a6..57031cca1 100644 --- a/packages/tests/unit/provisioning-jobs.test.ts +++ b/packages/tests/unit/provisioning-jobs.test.ts @@ -567,7 +567,7 @@ describe("ProvisioningJobService", () => { ok: true, status: 200, }); - global.fetch = mockFetch; + global.fetch = mockFetch as unknown as typeof fetch; await service.processPendingJobs(5); @@ -622,7 +622,7 @@ describe("ProvisioningJobService", () => { ); const mockFetch = vi.fn(); - global.fetch = mockFetch as typeof fetch; + global.fetch = mockFetch as unknown as typeof fetch; await service.processPendingJobs(5); diff --git a/packages/tests/unit/referrals-service.test.ts b/packages/tests/unit/referrals-service.test.ts index 7584d82fc..6adf8cd4e 100644 --- a/packages/tests/unit/referrals-service.test.ts +++ b/packages/tests/unit/referrals-service.test.ts @@ -120,7 +120,7 @@ describe("referralsService", () => { const result = await referralsService.getOrCreateCode("user-1"); - expect(result).toEqual({ + expect(result).toMatchObject({ id: "existing-code", user_id: "user-1", code: "ABCD-1234", @@ -145,7 +145,7 @@ describe("referralsService", () => { const result = await referralsService.getOrCreateCode("user-1"); - expect(result).toEqual({ + expect(result).toMatchObject({ id: "new-code", user_id: "user-1", code: "UNIQ-123", diff --git a/packages/tests/unit/security-validations.test.ts b/packages/tests/unit/security-validations.test.ts index a6fb04f6d..f30c9f655 100644 --- a/packages/tests/unit/security-validations.test.ts +++ b/packages/tests/unit/security-validations.test.ts @@ -134,7 +134,8 @@ describe("Security Validations", () => { // JSON.parse creates objects without prototype pollution // The __proto__ becomes a regular property expect(Object.hasOwn(parsed, "__proto__")).toBe(true); - expect({}.polluted).toBeUndefined(); // Global prototype not polluted + const pristine: Record = {}; + expect(pristine.polluted).toBeUndefined(); // Global prototype not polluted }); it("handles very large numbers correctly", () => { diff --git a/packages/tests/unit/token-agent-linkage.test.ts b/packages/tests/unit/token-agent-linkage.test.ts index fa88ca493..cb13b5170 100644 --- a/packages/tests/unit/token-agent-linkage.test.ts +++ b/packages/tests/unit/token-agent-linkage.test.ts @@ -365,9 +365,13 @@ describe("Token lookup logic", () => { describe("by-token endpoint validation", () => { test("requires address query parameter", () => { - const address = null; - const isValid = address != null && address.length > 0; - expect(isValid).toBe(false); + const scenarios: { address: string | null; expectValid: boolean }[] = [ + { address: null, expectValid: false }, + ]; + for (const { address, expectValid } of scenarios) { + const isValid = address !== null && address.length > 0; + expect(isValid).toBe(expectValid); + } }); test("accepts address with optional chain", () => { diff --git a/packages/tests/unit/v1-milaidy-provision-route.test.ts b/packages/tests/unit/v1-milaidy-provision-route.test.ts index 3edaba686..374bea656 100644 --- a/packages/tests/unit/v1-milaidy-provision-route.test.ts +++ b/packages/tests/unit/v1-milaidy-provision-route.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { mockMiladyPricingMinimumDepositForRouteTests } from "../helpers/mock-milady-pricing-for-route-tests"; import { routeParams } from "./api/route-test-helpers"; const mockRequireAuthOrApiKeyWithOrg = mock(); @@ -43,9 +44,7 @@ mock.module("@/lib/services/milady-billing-gate", () => ({ checkMiladyCreditGate: mockCheckMiladyCreditGate, })); -mock.module("@/lib/constants/milady-pricing", () => ({ - MILADY_PRICING: { MINIMUM_DEPOSIT: 5 }, -})); +mockMiladyPricingMinimumDepositForRouteTests(5); mock.module("@/lib/utils/logger", () => ({ logger: { diff --git a/packages/tests/unit/waifu-bridge.test.ts b/packages/tests/unit/waifu-bridge.test.ts index 1a857aec9..bfff28b78 100644 --- a/packages/tests/unit/waifu-bridge.test.ts +++ b/packages/tests/unit/waifu-bridge.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { canAutoCreateWaifuBridgeOrg } from "@/lib/auth/waifu-bridge"; +const mutableEnv = process.env as Record; + describe("waifu bridge auth policy", () => { const savedNodeEnv = process.env.NODE_ENV; const savedAutoCreate = process.env.WAIFU_BRIDGE_ALLOW_ORG_AUTO_CREATE; @@ -10,7 +12,7 @@ describe("waifu bridge auth policy", () => { }); afterEach(() => { - process.env.NODE_ENV = savedNodeEnv; + mutableEnv.NODE_ENV = savedNodeEnv; if (savedAutoCreate === undefined) { delete process.env.WAIFU_BRIDGE_ALLOW_ORG_AUTO_CREATE; } else { @@ -19,26 +21,26 @@ describe("waifu bridge auth policy", () => { }); test("disables auto-creating orgs in production by default", () => { - process.env.NODE_ENV = "production"; + mutableEnv.NODE_ENV = "production"; expect(canAutoCreateWaifuBridgeOrg()).toBe(false); }); test("disables auto-creating orgs in development by default (no longer relies on NODE_ENV)", () => { - process.env.NODE_ENV = "development"; + mutableEnv.NODE_ENV = "development"; expect(canAutoCreateWaifuBridgeOrg()).toBe(false); }); test("disables auto-creating orgs in preview/staging by default", () => { - process.env.NODE_ENV = "test"; + mutableEnv.NODE_ENV = "test"; expect(canAutoCreateWaifuBridgeOrg()).toBe(false); }); test("allows explicit opt-in for org auto-creation regardless of NODE_ENV", () => { - process.env.NODE_ENV = "production"; + mutableEnv.NODE_ENV = "production"; process.env.WAIFU_BRIDGE_ALLOW_ORG_AUTO_CREATE = "true"; expect(canAutoCreateWaifuBridgeOrg()).toBe(true); - process.env.NODE_ENV = "development"; + mutableEnv.NODE_ENV = "development"; expect(canAutoCreateWaifuBridgeOrg()).toBe(true); }); diff --git a/packages/tests/unit/wallet-auth.test.ts b/packages/tests/unit/wallet-auth.test.ts index 8c153b6cf..1836ba0dc 100644 --- a/packages/tests/unit/wallet-auth.test.ts +++ b/packages/tests/unit/wallet-auth.test.ts @@ -10,7 +10,7 @@ mock.module("@/lib/services/wallet-signup", () => ({ findOrCreateUserByWalletAddress: mockFindOrCreate, })); -const mockCacheSetIfNotExists = mock(() => true); +const mockCacheSetIfNotExists = mock(); const mockCacheIsAvailable = mock(() => true); mock.module("@/lib/cache/client", () => ({ @@ -47,7 +47,7 @@ describe("Wallet Authentication", () => { }; mockCacheGet.mockResolvedValue(null); - mockCacheSet.mockResolvedValue(undefined as never); + mockCacheSet.mockResolvedValue(undefined); mockCacheSetIfNotExists.mockResolvedValue(true); mockFindOrCreate.mockResolvedValue({ user: { diff --git a/packages/tests/unit/x402/facilitator-service.test.ts b/packages/tests/unit/x402/facilitator-service.test.ts index 75c51edff..155af9ed1 100644 --- a/packages/tests/unit/x402/facilitator-service.test.ts +++ b/packages/tests/unit/x402/facilitator-service.test.ts @@ -298,11 +298,21 @@ describe("402 Response Format", () => { describe("Edge Cases & Fuzz", () => { it("should handle missing authorization fields", () => { const payload = createValidPayload(); - // Remove a required field - const broken = { ...payload }; - (broken.payload as Record).authorization = {}; + // Create a broken payload with empty authorization to test validation + type BrokenPaymentPayload = Omit & { + payload: Omit & { + authorization: Partial; + }; + }; + const broken: BrokenPaymentPayload = { + ...payload, + payload: { + ...payload.payload, + authorization: {}, + }, + }; - expect((broken.payload.authorization as Record).from).toBeUndefined(); + expect(broken.payload.authorization.from).toBeUndefined(); }); it("should handle empty signature", () => { diff --git a/packages/tests/unit/milady-billing-route.test.ts b/packages/tests/unit/z-milady-billing-route.test.ts similarity index 76% rename from packages/tests/unit/milady-billing-route.test.ts rename to packages/tests/unit/z-milady-billing-route.test.ts index 52394440f..e2daa5fee 100644 --- a/packages/tests/unit/milady-billing-route.test.ts +++ b/packages/tests/unit/z-milady-billing-route.test.ts @@ -1,3 +1,19 @@ +/** + * Unit tests for `GET/POST /api/cron/milady-billing`. + * + * **Why `z-` in the filename:** `package.json` routes this file through `test:repo-unit:special` (and + * excludes it from bulk); the prefix is a loose sort-key reminder, not a runtime requirement. + * + * **Why `registerMiladyBillingMocks()` in `beforeEach`:** Other unit files call `mock.module("@/db/client")`. + * Re-applying our mocks each test avoids stale module doubles when the full tree runs. + * + * **Why inline `select` / `update` / `transaction` factories (not `mock()`):** Keeps queue-backed + * implementations reliably bound to this file’s arrays after repeated `mock.module` registration. + * + * **Why never mock `milady-pricing` here with `{ MINIMUM_DEPOSIT }` only:** Use + * `mockMiladyPricingMinimumDepositForRouteTests` in *route* tests that need a deposit override; partial + * pricing objects break this handler’s imports in-process (see docs/unit-testing-milady-mocks.md). + */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; @@ -61,16 +77,11 @@ function createInsertBuilder(queue: unknown[][]) { }; } -const mockDbReadSelect = mock(() => createReadBuilder(readResultsQueue.shift() ?? [])); -const mockDbWriteUpdate = mock(() => - createUpdateBuilder(writeUpdateResultsQueue, writeUpdateSetCalls), -); -const mockDbWriteTransaction = mock(async (callback: (tx: any) => Promise) => - callback({ - update: () => createUpdateBuilder(txUpdateResultsQueue, txUpdateSetCalls), - insert: () => createInsertBuilder(txInsertResultsQueue), - }), -); +type MiladyBillingTestTx = { + update: () => ReturnType; + insert: () => ReturnType; +}; + const mockListByOrganization = mock(async () => []); const mockSendContainerShutdownWarningEmail = mock(async () => undefined); const mockTrackServerEvent = mock(() => undefined); @@ -80,38 +91,47 @@ const mockLogger = { error: mock(() => undefined), }; -mock.module("@/db/client", () => ({ - dbRead: { - select: mockDbReadSelect, - }, - dbWrite: { - update: mockDbWriteUpdate, - transaction: mockDbWriteTransaction, - }, -})); - -mock.module("@/db/repositories", () => ({ - usersRepository: { - listByOrganization: mockListByOrganization, - }, -})); - -mock.module("@/lib/services/email", () => ({ - emailService: { - sendContainerShutdownWarningEmail: mockSendContainerShutdownWarningEmail, - }, -})); - -mock.module("@/lib/analytics/posthog-server", () => ({ - trackServerEvent: mockTrackServerEvent, -})); - -mock.module("@/lib/utils/logger", () => ({ - logger: mockLogger, -})); +function registerMiladyBillingMocks(): void { + // Inline closures: Bun `mock()` wrappers were order-sensitive when another file replaced `@/db/client`. + mock.module("@/db/client", () => ({ + dbRead: { + select: () => createReadBuilder(readResultsQueue.shift() ?? []), + }, + dbWrite: { + update: () => createUpdateBuilder(writeUpdateResultsQueue, writeUpdateSetCalls), + transaction: async (callback: (tx: MiladyBillingTestTx) => Promise): Promise => + callback({ + update: () => createUpdateBuilder(txUpdateResultsQueue, txUpdateSetCalls), + insert: () => createInsertBuilder(txInsertResultsQueue), + }), + }, + })); + + mock.module("@/db/repositories", () => ({ + usersRepository: { + listByOrganization: mockListByOrganization, + }, + })); + + mock.module("@/lib/services/email", () => ({ + emailService: { + sendContainerShutdownWarningEmail: mockSendContainerShutdownWarningEmail, + }, + })); + + mock.module("@/lib/analytics/posthog-server", () => ({ + trackServerEvent: mockTrackServerEvent, + })); + + mock.module("@/lib/utils/logger", () => ({ + logger: mockLogger, + })); +} + +registerMiladyBillingMocks(); async function importRoute() { - return await import("@/app/api/cron/milady-billing/route"); + return import("@/app/api/cron/milady-billing/route"); } function createRequest(): NextRequest { @@ -142,6 +162,8 @@ function enqueueBaseReadState({ describe("Milady billing cron", () => { beforeEach(() => { + registerMiladyBillingMocks(); + previousCronSecret = process.env.CRON_SECRET; previousAppUrl = process.env.NEXT_PUBLIC_APP_URL; @@ -155,22 +177,6 @@ describe("Milady billing cron", () => { process.env.CRON_SECRET = TEST_SECRET; process.env.NEXT_PUBLIC_APP_URL = "https://example.com"; - mockDbReadSelect.mockClear(); - mockDbReadSelect.mockImplementation(() => createReadBuilder(readResultsQueue.shift() ?? [])); - - mockDbWriteUpdate.mockClear(); - mockDbWriteUpdate.mockImplementation(() => - createUpdateBuilder(writeUpdateResultsQueue, writeUpdateSetCalls), - ); - - mockDbWriteTransaction.mockClear(); - mockDbWriteTransaction.mockImplementation(async (callback: (tx: any) => Promise) => - callback({ - update: () => createUpdateBuilder(txUpdateResultsQueue, txUpdateSetCalls), - insert: () => createInsertBuilder(txInsertResultsQueue), - }), - ); - mockListByOrganization.mockClear(); mockListByOrganization.mockResolvedValue([]); mockSendContainerShutdownWarningEmail.mockClear(); @@ -248,8 +254,8 @@ describe("Milady billing cron", () => { orgBalance: "1.0000", }); - // Fresh balance lookup used by queueShutdownWarning after the debit guard fails. - readResultsQueue.push([{ credit_balance: "0.0010" }]); + // getOrgBalance in queueShutdownWarning + second refresh after warning_sent in the handler loop + readResultsQueue.push([{ credit_balance: "0.0010" }], [{ credit_balance: "0.0010" }]); txUpdateResultsQueue.push([{ id: "sandbox-1" }], []); writeUpdateResultsQueue.push([]); diff --git a/packages/ui/src/styled-jsx.d.ts b/packages/ui/src/styled-jsx.d.ts new file mode 100644 index 000000000..f7fcb9ced --- /dev/null +++ b/packages/ui/src/styled-jsx.d.ts @@ -0,0 +1,8 @@ +import "react"; + +declare module "react" { + interface StyleHTMLAttributes { + jsx?: boolean; + global?: boolean; + } +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index a234db201..4fdeb8fcb 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -15,9 +15,15 @@ "baseUrl": ".", "types": ["node"], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@/lib/*": ["../lib/*"], + "@/db/*": ["../db/*"], + "@/types/*": ["../types/*"], + "@/components/*": ["./src/components/*"], + "@/app/*": ["../../app/*"], + "@/packages/ui/src/*": ["./src/*"] } }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.stories.ts", "**/*.stories.tsx"] } diff --git a/services/agent-server/package.json b/services/agent-server/package.json index e2dab372f..2d1996b9f 100644 --- a/services/agent-server/package.json +++ b/services/agent-server/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "start": "bun run src/index.ts", - "dev": "bun --watch run src/index.ts" + "dev": "bun --watch run src/index.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "@elizaos/core": "2.0.0-alpha.33", diff --git a/services/agent-server/src/routes.ts b/services/agent-server/src/routes.ts index badc6b350..0f8253544 100644 --- a/services/agent-server/src/routes.ts +++ b/services/agent-server/src/routes.ts @@ -17,7 +17,11 @@ function getAuthToken(headers: HeaderMap): string | null { return null; } -function requireInternalAuth(headers: HeaderMap, set: { status?: number }, sharedSecret: string) { +function requireInternalAuth( + headers: HeaderMap, + set: { status?: number | string }, + sharedSecret: string, +) { if (!sharedSecret) { set.status = 503; return { error: "Server auth not configured" }; diff --git a/tsconfig.test.json b/tsconfig.test.json index 2430823a6..4e0ca0366 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -18,11 +18,14 @@ "@/db/*": ["./packages/db/*"], "@/tests/*": ["./packages/tests/*"], "@/types/*": ["./packages/types/*"], + "@elizaos/cloud-ui": ["./packages/ui/src/index.ts"], + "@elizaos/cloud-ui/*": ["./packages/ui/src/*"], "@/*": ["./*"] } }, "include": [ "types/**/*.d.ts", + "./packages/types/**/*.d.ts", "./packages/tests/**/*.ts", "tests/**/*.tsx", "./packages/lib/**/*.ts", @@ -32,5 +35,14 @@ "components/**/*.ts", "./packages/ui/src/components/**/*.tsx" ], - "exclude": ["node_modules", ".next", "out", "build", "dist"] + "exclude": [ + "node_modules", + ".next", + "out", + "build", + "dist", + "./packages/ui/src/components/**/*.stories.tsx", + "./packages/ui/src/components/**/*.stories.ts", + "./packages/ui/src/components/**/*.test.tsx" + ] }