import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; // --------------------------------------------------------------------------- // Mocks — declared before imports that depend on platform/logger // --------------------------------------------------------------------------- const WORKSPACE_DIR = process.env.VELLUM_WORKSPACE_DIR!; const CONFIG_PATH = join(WORKSPACE_DIR, "config.json"); function ensureTestDir(): void { const dirs = [ WORKSPACE_DIR, join(WORKSPACE_DIR, "data"), join(WORKSPACE_DIR, "data", "memory"), join(WORKSPACE_DIR, "data", "memory", "knowledge"), join(WORKSPACE_DIR, "data", "logs"), ]; for (const dir of dirs) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } } import { invalidateConfigCache, loadConfig } from "../config/loader.js"; import { AssistantConfigSchema, DEFAULT_ELEVENLABS_VOICE_ID, } from "../config/schema.js"; import { SttServiceSchema } from "../config/schemas/stt.js"; import { TtsServiceSchema } from "../config/schemas/tts.js"; import type { AssistantConfig } from "../config/types.js"; import { listCatalogProviderIds } from "../tts/provider-catalog.js"; import { resolveTtsConfig } from "../tts/tts-config-resolver.js"; import { TTS_PROVIDER_IDS } from "../tts/types.js"; import { setStorePathForTesting } from "./encrypted-store-test-helpers.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function writeConfig(obj: unknown): void { writeFileSync(CONFIG_PATH, JSON.stringify(obj)); } // --------------------------------------------------------------------------- // Tests: Zod schema (unit) // --------------------------------------------------------------------------- describe("AssistantConfigSchema", () => { test("parses empty object with full defaults", () => { const result = AssistantConfigSchema.parse({}); // services.inference is an empty object; model selection lives under // llm.profiles / llm.callSites, auth routing via provider_connections. expect(result.services.inference).toEqual({}); expect(result.llm.profiles).toEqual({}); expect(result.llm.profileOrder).toEqual([]); expect(result.llm.callSites).toEqual({}); expect(result.services["image-generation"].provider).toBe("gemini"); expect(result.services["image-generation"].model).toBe( "gemini-3.1-flash-image-preview", ); expect(result.services["image-generation"]).not.toHaveProperty("mode"); expect(result.services["web-search"].provider).toBe( "inference-provider-native", ); expect(result.services["web-search"]).not.toHaveProperty("mode"); expect(result.llm.profileSession).toEqual({ defaultTtlSeconds: 1800, maxTtlSeconds: 43200, }); expect(result.timeouts).toEqual({ shellDefaultTimeoutSec: 120, shellMaxTimeoutSec: 600, permissionTimeoutSec: 300, questionResponseTimeoutSec: 1800, toolExecutionTimeoutSec: 120, providerStreamTimeoutSec: 1800, backgroundTurnTimeoutSec: 1800, scheduleTurnTimeoutSec: 1800, }); expect(result.rateLimit).toEqual({ maxRequestsPerMinute: 0, }); expect(result.secretDetection).toEqual({ enabled: true, blockIngress: true, allowOneTimeSend: false, blockTokenShapedMessages: true, }); expect(result.auditLog).toEqual({ retentionDays: 0 }); }); test("accepts vellum as an image generation provider", () => { const result = AssistantConfigSchema.parse({ services: { "image-generation": { provider: "vellum", model: "gpt-image-2" }, }, }); expect(result.services["image-generation"].provider).toBe("vellum"); expect(result.services["image-generation"].model).toBe("gpt-image-2"); }); test("accepts Tavily as a web search provider", () => { const result = AssistantConfigSchema.parse({ services: { "web-search": { mode: "your-own", provider: "tavily" }, }, }); expect(result.services["web-search"].provider).toBe("tavily"); // A mode key sent by an older client is stripped at parse. expect(result.services["web-search"]).not.toHaveProperty("mode"); }); test("accepts Firecrawl as a web search provider", () => { const result = AssistantConfigSchema.parse({ services: { "web-search": { mode: "your-own", provider: "firecrawl" }, }, }); expect(result.services["web-search"].provider).toBe("firecrawl"); expect(result.services["web-search"]).not.toHaveProperty("mode"); }); test("defaults the web-fetch provider to the built-in fetcher", () => { const result = AssistantConfigSchema.parse({}); expect(result.services["web-fetch"].provider).toBe("default"); }); test("accepts Firecrawl as a web fetch provider", () => { const result = AssistantConfigSchema.parse({ services: { "web-fetch": { provider: "firecrawl" }, }, }); expect(result.services["web-fetch"].provider).toBe("firecrawl"); }); test("rejects an unknown web-fetch provider", () => { expect(() => AssistantConfigSchema.parse({ services: { "web-fetch": { provider: "nope" } }, }), ).toThrow(); }); // Legacy config objects on disk may carry a `mode` key; parsing must strip // it rather than reject the whole config. test("ignores a legacy web-fetch mode key", () => { const result = AssistantConfigSchema.parse({ services: { "web-fetch": { mode: "your-own", provider: "firecrawl" }, }, }); expect(result.services["web-fetch"]).toEqual({ provider: "firecrawl" }); }); test("accepts valid complete config", () => { const input = { llm: { callSites: { mainAgent: { provider: "openai" as const, model: "gpt-4", maxTokens: 4096, }, }, }, timeouts: { shellDefaultTimeoutSec: 30, shellMaxTimeoutSec: 300, permissionTimeoutSec: 60, }, rateLimit: { maxRequestsPerMinute: 10 }, secretDetection: { enabled: false, blockIngress: false, }, auditLog: { retentionDays: 30 }, }; const result = AssistantConfigSchema.parse(input); expect(result.llm.callSites?.mainAgent?.provider).toBe("openai"); expect(result.llm.callSites?.mainAgent?.model).toBe("gpt-4"); expect(result.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(result.secretDetection.enabled).toBe(false); }); test("applies llm defaults when llm key is omitted", () => { const result = AssistantConfigSchema.parse({}); expect(result.llm).toBeDefined(); expect(result.llm.profiles).toEqual({}); expect(result.llm.profileOrder).toEqual([]); expect(result.llm.callSites).toEqual({}); expect(result.llm.pricingOverrides).toEqual([]); expect(result.llm.profileSession).toEqual({ defaultTtlSeconds: 1800, maxTtlSeconds: 43200, }); }); test("accepts an explicit llm block with profiles and call sites", () => { const input = { llm: { profiles: { fast: { speed: "fast" as const, effort: "low" as const }, }, profileOrder: ["fast"], callSites: { mainAgent: { profile: "fast" }, commitMessage: { maxTokens: 256 }, }, pricingOverrides: [], }, }; const result = AssistantConfigSchema.parse(input); expect(result.llm.profiles?.fast).toEqual({ speed: "fast", effort: "low", }); expect(result.llm.profileOrder).toEqual(["fast"]); expect(result.llm.callSites?.mainAgent).toEqual({ profile: "fast" }); expect(result.llm.callSites?.commitMessage).toEqual({ maxTokens: 256 }); }); test("rejects an llm.callSites entry that references an undefined profile", () => { const input = { llm: { callSites: { mainAgent: { profile: "missing-profile" }, }, }, }; expect(() => AssistantConfigSchema.parse(input)).toThrow(/missing-profile/); }); test("legacy top-level inference keys are ignored after PR 19 cleanup", () => { // The legacy keys (top-level maxTokens, effort, speed, thinking, // contextWindow, services.inference.{provider,model}) were removed in PR // 19. Configs that still carry them parse cleanly because Zod strips // unknown fields, and migration 039 erases them from the on-disk file // entirely. const input = { services: { inference: { provider: "openai", model: "gpt-4" }, }, maxTokens: 8000, effort: "medium", speed: "fast", thinking: { enabled: false, streamThinking: false }, }; const result = AssistantConfigSchema.parse(input); expect((result as Record).maxTokens).toBeUndefined(); expect((result as Record).effort).toBeUndefined(); expect((result as Record).speed).toBeUndefined(); expect((result as Record).thinking).toBeUndefined(); expect( (result.services.inference as Record).provider, ).toBeUndefined(); expect( (result.services.inference as Record).model, ).toBeUndefined(); expect(result.llm.profiles).toEqual({}); expect(result.llm.callSites).toEqual({}); }); test("partial llm config doesn't trigger full config reset", () => { // Regression guard: schema-level leaf defaults mean a partial `llm` // block parses cleanly instead of failing validation, so the loader's // recovery path never falls through to `cloneDefaultConfig()` and the // user's other settings are preserved. const result = AssistantConfigSchema.parse({ llm: { profileSession: { defaultTtlSeconds: 900 } }, }); expect(result.llm.profileSession.defaultTtlSeconds).toBe(900); expect(result.llm.profileSession.maxTtlSeconds).toBe(43200); expect(result.llm.profiles).toEqual({}); }); test("legacy llm.default blob parses without error and is ignored", () => { // Load-time backwards compat: `llm.default` is not part of the schema. // Old configs that still carry the blob parse cleanly because Zod strips // unknown keys — the parsed config simply has no `default` key. const result = AssistantConfigSchema.parse({ llm: { default: { provider: "openai", model: "gpt-4" }, profiles: {} }, }); expect((result.llm as Record).default).toBeUndefined(); expect(result.llm.profiles).toEqual({}); }); test("applies rollout defaults for dynamic budget", () => { const result = AssistantConfigSchema.parse({}); expect(result.memory.retrieval.dynamicBudget).toEqual({ enabled: true, minInjectTokens: 2400, maxInjectTokens: 16000, targetHeadroomTokens: 10000, }); }); test("scratchpad injection defaults to enabled", () => { const result = AssistantConfigSchema.parse({}); expect(result.memory.retrieval.scratchpadInjection).toEqual({ enabled: true, }); }); test("scratchpad injection accepts disabled override", () => { const result = AssistantConfigSchema.parse({ memory: { retrieval: { scratchpadInjection: { enabled: false } } }, }); expect(result.memory.retrieval.scratchpadInjection.enabled).toBe(false); }); test("scratchpad injection rejects non-boolean enabled", () => { const result = AssistantConfigSchema.safeParse({ memory: { retrieval: { scratchpadInjection: { enabled: "yes" } } }, }); expect(result.success).toBe(false); }); test("applies memory.cleanup defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.memory.cleanup).toEqual({ enabled: true, supersededItemRetentionMs: 30 * 24 * 60 * 60 * 1000, conversationRetentionDays: 0, llmRequestLogRetentionMs: 1 * 60 * 60 * 1000, }); }); test("accepts memory.cleanup.llmRequestLogRetentionMs at the 365-day boundary", () => { const max = 365 * 24 * 60 * 60 * 1000; const result = AssistantConfigSchema.safeParse({ memory: { cleanup: { llmRequestLogRetentionMs: max } }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.memory.cleanup.llmRequestLogRetentionMs).toBe(max); } }); test("rejects memory.cleanup.llmRequestLogRetentionMs above 365 days", () => { // This must match the gateway's MAX_LLM_REQUEST_LOG_RETENTION_MS. Without // the Zod .max(), a manually edited config.json with a large value would // be silently accepted by the daemon and then truncated by the macOS // picker on the next PATCH — a quiet data-loss bug. const overMax = 365 * 24 * 60 * 60 * 1000 + 1; const result = AssistantConfigSchema.safeParse({ memory: { cleanup: { llmRequestLogRetentionMs: overMax } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.includes("llmRequestLogRetentionMs"), ), ).toBe(true); } }); test("rejects negative memory.cleanup.llmRequestLogRetentionMs", () => { const result = AssistantConfigSchema.safeParse({ memory: { cleanup: { llmRequestLogRetentionMs: -1 } }, }); expect(result.success).toBe(false); }); test("accepts null memory.cleanup.llmRequestLogRetentionMs (keep forever)", () => { const result = AssistantConfigSchema.safeParse({ memory: { cleanup: { llmRequestLogRetentionMs: null } }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.memory.cleanup.llmRequestLogRetentionMs).toBeNull(); } }); test("accepts memory.cleanup.llmRequestLogRetentionMs: 0 (disables pruning)", () => { const result = AssistantConfigSchema.safeParse({ memory: { cleanup: { llmRequestLogRetentionMs: 0 } }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.memory.cleanup.llmRequestLogRetentionMs).toBe(0); } }); test("parses an unknown provider (read tolerance for entry names)", () => { // The provider schema is an open string: a stored value outside the // known set parses instead of stripping its profile, and dispatch // resolves it as a connection entry name (or fails explainably). // Write-time membership is enforced at the profiles route and the // config-write choke point. const result = AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "anthropic-work" } } }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.llm.profiles.custom?.provider).toBe("anthropic-work"); } }); test("accepts the vellum and chatgpt routing identities with a routable model", () => { const inProfile = AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "vellum", model: "claude-opus-4-8" }, }, }, }); expect(inProfile.success).toBe(true); const inCallSite = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { provider: "chatgpt", model: "gpt-5.5" } }, }, }); expect(inCallSite.success).toBe(true); }); test("rejects routing identities with a missing or unroutable model", () => { // A call-site fragment naming an identity without a model would inherit // the winning profile's model, which the identity may not serve. expect( AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { provider: "chatgpt" } } }, }).success, ).toBe(false); expect( AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "vellum" } } }, }).success, ).toBe(false); expect( AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "vellum", model: "not-a-real-model" }, }, }, }).success, ).toBe(false); expect( AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "chatgpt", model: "gpt-4o" } }, }, }).success, ).toBe(false); // Encoded routing strings are a telemetry/display codec, not a stored // model id — dispatch would pass one to the upstream adapter verbatim. expect( AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider: "vellum", model: "fireworks/accounts/fireworks/models/glm-5p2", }, }, }, }).success, ).toBe(false); }); test("rejects negative llm.callSites maxTokens", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { maxTokens: -100 } } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.includes("maxTokens")), ).toBe(true); } }); test("rejects non-integer llm.callSites maxTokens", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { maxTokens: 3.14 } } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.includes("maxTokens")), ).toBe(true); } }); test("rejects string llm.callSites maxTokens", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { maxTokens: "not-a-number" } } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.includes("maxTokens")), ).toBe(true); } }); test("rejects invalid timeout values", () => { const result = AssistantConfigSchema.safeParse({ timeouts: { shellDefaultTimeoutSec: -5, shellMaxTimeoutSec: "bad", permissionTimeoutSec: 0, }, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.length).toBeGreaterThanOrEqual(3); } }); test("rejects invalid thinking config", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { thinking: { enabled: "yes" } } } }, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.length).toBeGreaterThanOrEqual(1); } }); test("rejects out-of-range contextWindow targetBudgetRatio", () => { for (const bad of [0, -0.1, 1.5]) { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { contextWindow: { targetBudgetRatio: bad } }, }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("targetBudgetRatio"), ), ).toBe(true); } } }); test("rejects overflowRecovery safetyMarginRatio out of (0,1) range", () => { for (const bad of [0, 1, -0.1, 1.5]) { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { contextWindow: { overflowRecovery: { safetyMarginRatio: bad } }, }, }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("safetyMarginRatio"), ), ).toBe(true); } } }); test("rejects invalid overflowRecovery interactiveLatestTurnCompression", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { contextWindow: { overflowRecovery: { interactiveLatestTurnCompression: "explode" }, }, }, }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("interactiveLatestTurnCompression"), ), ).toBe(true); } }); test("rejects invalid overflowRecovery nonInteractiveLatestTurnCompression", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { contextWindow: { overflowRecovery: { nonInteractiveLatestTurnCompression: "nope" }, }, }, }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("nonInteractiveLatestTurnCompression"), ), ).toBe(true); } }); test("rejects negative rateLimit values", () => { const result = AssistantConfigSchema.safeParse({ rateLimit: { maxRequestsPerMinute: -1 }, }); expect(result.success).toBe(false); }); // ── apiRateLimit config (authenticated /v1/* API limiter) ──────────── test("applies apiRateLimit default of 300 when unset", () => { const result = AssistantConfigSchema.parse({}); expect(result.apiRateLimit).toEqual({ authenticatedMaxRequestsPerMinute: 300, }); }); test("accepts a custom apiRateLimit.authenticatedMaxRequestsPerMinute override", () => { const result = AssistantConfigSchema.parse({ apiRateLimit: { authenticatedMaxRequestsPerMinute: 600 }, }); expect(result.apiRateLimit.authenticatedMaxRequestsPerMinute).toBe(600); }); test("rejects zero, negative, and non-integer apiRateLimit budgets", () => { for (const bad of [0, -1, 12.5, "300"]) { const result = AssistantConfigSchema.safeParse({ apiRateLimit: { authenticatedMaxRequestsPerMinute: bad }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.join(".").includes("authenticatedMaxRequestsPerMinute"), ), ).toBe(true); } } }); test("rejects negative auditLog.retentionDays", () => { const result = AssistantConfigSchema.safeParse({ auditLog: { retentionDays: -7 }, }); expect(result.success).toBe(false); }); test("accepts partial nested objects with defaults", () => { const result = AssistantConfigSchema.parse({ timeouts: { shellDefaultTimeoutSec: 30 }, }); expect(result.timeouts.shellDefaultTimeoutSec).toBe(30); expect(result.timeouts.shellMaxTimeoutSec).toBe(600); expect(result.timeouts.permissionTimeoutSec).toBe(300); }); test("background/schedule turn timeouts default to 1800s when unset", () => { const result = AssistantConfigSchema.parse({ timeouts: { shellDefaultTimeoutSec: 30 }, }); expect(result.timeouts.backgroundTurnTimeoutSec).toBe(1800); expect(result.timeouts.scheduleTurnTimeoutSec).toBe(1800); }); test("custom background/schedule turn timeouts flow through to resolved config", () => { const result = AssistantConfigSchema.parse({ timeouts: { backgroundTurnTimeoutSec: 3600, scheduleTurnTimeoutSec: 10800, }, }); expect(result.timeouts.backgroundTurnTimeoutSec).toBe(3600); expect(result.timeouts.scheduleTurnTimeoutSec).toBe(10800); }); test("rejects non-integer and out-of-range turn timeouts", () => { expect( AssistantConfigSchema.safeParse({ timeouts: { backgroundTurnTimeoutSec: 12.5 }, }).success, ).toBe(false); expect( AssistantConfigSchema.safeParse({ timeouts: { scheduleTurnTimeoutSec: 2147484 }, }).success, ).toBe(false); }); test("accepts zero for non-negative fields", () => { const result = AssistantConfigSchema.parse({ rateLimit: { maxRequestsPerMinute: 0 }, auditLog: { retentionDays: 0 }, }); expect(result.rateLimit.maxRequestsPerMinute).toBe(0); expect(result.auditLog.retentionDays).toBe(0); }); test("accepts all valid provider values", () => { for (const provider of [ "anthropic", "openai", "gemini", "ollama", ] as const) { const result = AssistantConfigSchema.safeParse({ llm: { profiles: { custom: { provider } } }, }); expect(result.success).toBe(true); } }); test("provides helpful error messages", () => { const result = AssistantConfigSchema.safeParse({ llm: { callSites: { mainAgent: { maxTokens: -1 } } }, }); expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((i) => i.message); // The maxTokens validation rejects -1 with a "Too small" // / "expected number to be >0" message from Zod's default issue text. expect( messages.some( (m) => m.includes("positive") || /expected number to be >0/i.test(m), ), ).toBe(true); } }); test("applies workspaceGit defaults including interactiveGitTimeoutMs", () => { const result = AssistantConfigSchema.parse({}); expect(result.workspaceGit).toEqual({ turnCommitMaxWaitMs: 4000, failureBackoffBaseMs: 2000, failureBackoffMaxMs: 60000, maxFileSizeBytes: 256000, historyCompaction: { enabled: true }, interactiveGitTimeoutMs: 10000, enrichmentQueueSize: 50, enrichmentConcurrency: 1, enrichmentJobTimeoutMs: 30000, enrichmentMaxRetries: 2, commitMessageLLM: { enabled: false, timeoutMs: 600, maxFilesInPrompt: 30, maxDiffBytes: 12000, minRemainingTurnBudgetMs: 1000, breaker: { openAfterFailures: 3, backoffBaseMs: 2000, backoffMaxMs: 60000, }, }, }); }); test("accepts custom workspaceGit.interactiveGitTimeoutMs", () => { const result = AssistantConfigSchema.parse({ workspaceGit: { interactiveGitTimeoutMs: 5000 }, }); expect(result.workspaceGit.interactiveGitTimeoutMs).toBe(5000); // Other fields should still get defaults expect(result.workspaceGit.turnCommitMaxWaitMs).toBe(4000); }); test("rejects non-positive workspaceGit.interactiveGitTimeoutMs", () => { const zeroResult = AssistantConfigSchema.safeParse({ workspaceGit: { interactiveGitTimeoutMs: 0 }, }); expect(zeroResult.success).toBe(false); const negativeResult = AssistantConfigSchema.safeParse({ workspaceGit: { interactiveGitTimeoutMs: -1 }, }); expect(negativeResult.success).toBe(false); }); test("rejects non-integer workspaceGit.interactiveGitTimeoutMs", () => { const result = AssistantConfigSchema.safeParse({ workspaceGit: { interactiveGitTimeoutMs: 3.5 }, }); expect(result.success).toBe(false); }); test("rejects non-number workspaceGit.interactiveGitTimeoutMs", () => { const result = AssistantConfigSchema.safeParse({ workspaceGit: { interactiveGitTimeoutMs: "fast" }, }); expect(result.success).toBe(false); }); // ── commitMessageLLM config ────────────────────────────────────────── test("default commitMessageLLM values are correct", () => { const result = AssistantConfigSchema.parse({}); const llm = result.workspaceGit.commitMessageLLM; expect(llm.enabled).toBe(false); expect(llm.timeoutMs).toBe(600); expect(llm.maxFilesInPrompt).toBe(30); expect(llm.maxDiffBytes).toBe(12000); expect(llm.minRemainingTurnBudgetMs).toBe(1000); }); test("rejects negative commitMessageLLM.timeoutMs", () => { const result = AssistantConfigSchema.safeParse({ workspaceGit: { commitMessageLLM: { timeoutMs: -1 } }, }); expect(result.success).toBe(false); }); test("breaker settings have correct defaults", () => { const result = AssistantConfigSchema.parse({}); const breaker = result.workspaceGit.commitMessageLLM.breaker; expect(breaker.openAfterFailures).toBe(3); expect(breaker.backoffBaseMs).toBe(2000); expect(breaker.backoffMaxMs).toBe(60000); }); test("accepts valid commitMessageLLM overrides", () => { const result = AssistantConfigSchema.parse({ workspaceGit: { commitMessageLLM: { enabled: true, timeoutMs: 1000, breaker: { openAfterFailures: 5 }, }, }, }); expect(result.workspaceGit.commitMessageLLM.enabled).toBe(true); expect(result.workspaceGit.commitMessageLLM.timeoutMs).toBe(1000); expect(result.workspaceGit.commitMessageLLM.breaker.openAfterFailures).toBe( 5, ); // Other breaker fields should still get defaults expect(result.workspaceGit.commitMessageLLM.breaker.backoffBaseMs).toBe( 2000, ); }); test("ignores legacy commitMessageLLM.{maxTokens,temperature} keys", () => { // PR 19 removed maxTokens/temperature from the schema; Zod silently // strips them on parse. Migration 039 erases them from disk so they // don't accumulate over time. const result = AssistantConfigSchema.parse({ workspaceGit: { commitMessageLLM: { maxTokens: 200, temperature: 0.5 }, }, }); const cm = result.workspaceGit.commitMessageLLM as Record; expect(cm.maxTokens).toBeUndefined(); expect(cm.temperature).toBeUndefined(); }); // ── Calls config ──────────────────────────────────────────────────── test("applies calls defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.calls).toEqual({ enabled: true, provider: "twilio", maxDurationSeconds: 3600, userConsultTimeoutSeconds: 120, ttsPlaybackDelayMs: 3000, accessRequestPollIntervalMs: 500, guardianWaitUpdateInitialIntervalMs: 15000, guardianWaitUpdateInitialWindowMs: 30000, guardianWaitUpdateSteadyMinIntervalMs: 20000, guardianWaitUpdateSteadyMaxIntervalMs: 30000, disclosure: { enabled: true, text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".', }, safety: { denyCategories: [], }, voice: { interruptSensitivity: "low", telephonyStreaming: true, utteranceEndMs: 1000, }, callerIdentity: { allowPerCallOverride: true, }, verification: { enabled: false, maxAttempts: 3, codeLength: 6, }, }); }); test("accepts valid calls config overrides", () => { const result = AssistantConfigSchema.parse({ calls: { enabled: false, maxDurationSeconds: 1800, userConsultTimeoutSeconds: 60, disclosure: { enabled: false, text: "Custom disclosure" }, safety: { denyCategories: ["spam"] }, }, }); expect(result.calls.enabled).toBe(false); expect(result.calls.maxDurationSeconds).toBe(1800); expect(result.calls.userConsultTimeoutSeconds).toBe(60); expect(result.calls.disclosure.enabled).toBe(false); expect(result.calls.disclosure.text).toBe("Custom disclosure"); expect(result.calls.safety.denyCategories).toEqual(["spam"]); }); // ── Live voice config ─────────────────────────────────────────────── test("applies liveVoice defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.liveVoice).toEqual({ mode: "open-mic", vad: { speechEnergyThreshold: 800, silenceThresholdMs: 1200, maxTurnDurationMs: 30000, bargeInMinSpeechMs: 250, echoBargeInMargin: 1.5, echoEmaHalfLifeMs: 400, echoDrainSlackMs: 300, }, frontModel: { endpointDecisionTimeoutMs: 1200, endpointExtensionMs: 1500, endpointMaxExtensions: 2, progress: { enabled: true, opsThreshold: 3, idleIntervalMs: 5000, maxSilenceMs: 35000, longOpMs: 15000, minGapMs: 6000, generationTimeoutMs: 1500, }, }, flux: { turnEnd: { enabled: false }, model: "flux-general-en", eotThreshold: 0.7, eotTimeoutMs: 5000, }, maxSessionDurationSeconds: 1800, archiveAudio: false, }); }); test("accepts valid liveVoice config overrides", () => { const result = AssistantConfigSchema.parse({ liveVoice: { mode: "ptt", vad: { speechEnergyThreshold: 1500, silenceThresholdMs: 1000, bargeInMinSpeechMs: 120, }, maxSessionDurationSeconds: 900, }, }); expect(result.liveVoice.mode).toBe("ptt"); expect(result.liveVoice.vad.speechEnergyThreshold).toBe(1500); expect(result.liveVoice.vad.silenceThresholdMs).toBe(1000); expect(result.liveVoice.vad.bargeInMinSpeechMs).toBe(120); // Unspecified vad fields still get defaults expect(result.liveVoice.vad.maxTurnDurationMs).toBe(30000); expect(result.liveVoice.maxSessionDurationSeconds).toBe(900); // A partial liveVoice override leaves Flux turn-end disabled by default. expect(result.liveVoice.flux.turnEnd.enabled).toBe(false); }); test("accepts a liveVoice.vad.bargeInMinSpeechMs of 0 (guard disabled)", () => { const result = AssistantConfigSchema.parse({ liveVoice: { vad: { bargeInMinSpeechMs: 0 } }, }); expect(result.liveVoice.vad.bargeInMinSpeechMs).toBe(0); }); test("rejects negative liveVoice.vad.bargeInMinSpeechMs", () => { const result = AssistantConfigSchema.safeParse({ liveVoice: { vad: { bargeInMinSpeechMs: -1 } }, }); expect(result.success).toBe(false); }); test("rejects non-integer liveVoice.vad.bargeInMinSpeechMs", () => { const result = AssistantConfigSchema.safeParse({ liveVoice: { vad: { bargeInMinSpeechMs: 60.5 } }, }); expect(result.success).toBe(false); }); test("accepts partial calls config with defaults for missing fields", () => { const result = AssistantConfigSchema.parse({ calls: { maxDurationSeconds: 600 }, }); expect(result.calls.enabled).toBe(true); expect(result.calls.maxDurationSeconds).toBe(600); expect(result.calls.userConsultTimeoutSeconds).toBe(120); expect(result.calls.provider).toBe("twilio"); }); test("rejects invalid calls.enabled", () => { const result = AssistantConfigSchema.safeParse({ calls: { enabled: "yes" }, }); expect(result.success).toBe(false); }); test("rejects invalid calls.provider", () => { const result = AssistantConfigSchema.safeParse({ calls: { provider: "vonage" }, }); expect(result.success).toBe(false); if (!result.success) { const msgs = result.error.issues.map((i) => i.message); expect(msgs.some((m) => m.includes("calls.provider"))).toBe(true); } }); test("rejects non-positive calls.maxDurationSeconds", () => { const result = AssistantConfigSchema.safeParse({ calls: { maxDurationSeconds: 0 }, }); expect(result.success).toBe(false); }); test("rejects non-integer calls.maxDurationSeconds", () => { const result = AssistantConfigSchema.safeParse({ calls: { maxDurationSeconds: 3.5 }, }); expect(result.success).toBe(false); }); test("rejects non-positive calls.userConsultTimeoutSeconds", () => { const result = AssistantConfigSchema.safeParse({ calls: { userConsultTimeoutSeconds: -1 }, }); expect(result.success).toBe(false); }); test("rejects non-boolean calls.disclosure.enabled", () => { const result = AssistantConfigSchema.safeParse({ calls: { disclosure: { enabled: "true" } }, }); expect(result.success).toBe(false); }); test("rejects non-string calls.disclosure.text", () => { const result = AssistantConfigSchema.safeParse({ calls: { disclosure: { text: 123 } }, }); expect(result.success).toBe(false); }); test("rejects non-array calls.safety.denyCategories", () => { const result = AssistantConfigSchema.safeParse({ calls: { safety: { denyCategories: "spam" } }, }); expect(result.success).toBe(false); }); // ── Calls voice config ────────────────────────────────────────────── test("config without calls.voice parses correctly and produces defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.calls.voice.interruptSensitivity).toBe("low"); expect(result.calls.voice.telephonyStreaming).toBe(true); expect(result.calls.voice.utteranceEndMs).toBe(1000); }); test("accepts valid calls.voice overrides", () => { const result = AssistantConfigSchema.parse({ calls: { voice: { telephonyStreaming: false, utteranceEndMs: 2500, }, }, }); expect(result.calls.voice.telephonyStreaming).toBe(false); expect(result.calls.voice.utteranceEndMs).toBe(2500); }); test("language is no longer part of the voice config schema", () => { // The retired knob was read by nothing; Zod strips the unrecognized key // so persisted configs that still carry it keep parsing. const result = AssistantConfigSchema.parse({ calls: { voice: { language: "es-ES" } }, }); expect( (result.calls.voice as Record).language, ).toBeUndefined(); }); test("rejects calls.voice.utteranceEndMs outside the 1000-5000 range", () => { for (const utteranceEndMs of [999, 5001]) { const result = AssistantConfigSchema.safeParse({ calls: { voice: { utteranceEndMs } }, }); expect(result.success).toBe(false); if (!result.success) { const msgs = result.error.issues.map((i) => i.message); expect(msgs.some((m) => m.includes("calls.voice.utteranceEndMs"))).toBe( true, ); } } }); test("rejects non-integer calls.voice.utteranceEndMs", () => { const result = AssistantConfigSchema.safeParse({ calls: { voice: { utteranceEndMs: 1000.5 } }, }); expect(result.success).toBe(false); }); test("rejects non-boolean calls.voice.telephonyStreaming", () => { const result = AssistantConfigSchema.safeParse({ calls: { voice: { telephonyStreaming: "yes" } }, }); expect(result.success).toBe(false); if (!result.success) { const msgs = result.error.issues.map((i) => i.message); expect( msgs.some((m) => m.includes("calls.voice.telephonyStreaming")), ).toBe(true); } }); test("transcriptionProvider is no longer part of the voice config schema", () => { // Zod strips unrecognized keys by default — the legacy field is silently ignored. const result = AssistantConfigSchema.parse({ calls: { voice: { transcriptionProvider: "Google" } }, }); expect( (result.calls.voice as Record).transcriptionProvider, ).toBeUndefined(); }); test("legacy calls.model key is stripped after PR 19 cleanup", () => { // calls.model moved to llm.callSites.callAgent.model in PR 4 and the // legacy field was removed in PR 19. Zod silently strips unknown keys. const result = AssistantConfigSchema.parse({ calls: { model: "claude-haiku-4-5-20251001" }, }); expect((result.calls as Record).model).toBeUndefined(); }); // ── Caller identity config ──────────────────────────────────────── test("applies calls.callerIdentity defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.calls.callerIdentity).toEqual({ allowPerCallOverride: true, }); }); test("accepts valid calls.callerIdentity overrides", () => { const result = AssistantConfigSchema.parse({ calls: { callerIdentity: { allowPerCallOverride: false, userNumber: "+14155559999", }, }, }); expect(result.calls.callerIdentity.allowPerCallOverride).toBe(false); expect(result.calls.callerIdentity.userNumber).toBe("+14155559999"); }); test("unknown defaultMode field is silently stripped by schema", () => { // Zod strips unrecognized keys by default. const result = AssistantConfigSchema.parse({ calls: { callerIdentity: { defaultMode: "user_number", allowPerCallOverride: true, }, }, }); expect( (result.calls.callerIdentity as Record).defaultMode, ).toBeUndefined(); expect(result.calls.callerIdentity.allowPerCallOverride).toBe(true); }); test("rejects non-boolean calls.callerIdentity.allowPerCallOverride", () => { const result = AssistantConfigSchema.safeParse({ calls: { callerIdentity: { allowPerCallOverride: "yes" } }, }); expect(result.success).toBe(false); }); test("default behavior unchanged when callerIdentity omitted", () => { const result = AssistantConfigSchema.parse({ calls: { enabled: true }, }); expect(result.calls.callerIdentity.allowPerCallOverride).toBe(true); }); // ── hostBrowser.cdpInspect config ───────────────────────────────── test("applies hostBrowser.cdpInspect defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.hostBrowser).toEqual({ cdpInspect: { enabled: false, host: "localhost", port: 9222, probeTimeoutMs: 500, desktopAuto: { enabled: true, cooldownMs: 30_000, }, }, }); }); test("accepts hostBrowser.cdpInspect enabled with custom host/port", () => { const result = AssistantConfigSchema.parse({ hostBrowser: { cdpInspect: { enabled: true, host: "127.0.0.1", port: 9333, }, }, }); expect(result.hostBrowser.cdpInspect.enabled).toBe(true); expect(result.hostBrowser.cdpInspect.host).toBe("127.0.0.1"); expect(result.hostBrowser.cdpInspect.port).toBe(9333); // Unset field should still receive its default. expect(result.hostBrowser.cdpInspect.probeTimeoutMs).toBe(500); }); test("accepts hostBrowser.cdpInspect custom probeTimeoutMs", () => { const result = AssistantConfigSchema.parse({ hostBrowser: { cdpInspect: { probeTimeoutMs: 1000 } }, }); expect(result.hostBrowser.cdpInspect.probeTimeoutMs).toBe(1000); }); test("rejects hostBrowser.cdpInspect.port below 1", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { port: 0 } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("hostBrowser.cdpInspect.port"), ), ).toBe(true); } }); test("rejects hostBrowser.cdpInspect.port above 65535", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { port: 70000 } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("hostBrowser.cdpInspect.port"), ), ).toBe(true); } }); test("rejects non-integer hostBrowser.cdpInspect.port", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { port: 9222.5 } }, }); expect(result.success).toBe(false); }); test("rejects hostBrowser.cdpInspect.probeTimeoutMs below 50", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { probeTimeoutMs: 10 } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path .join(".") .includes("hostBrowser.cdpInspect.probeTimeoutMs"), ), ).toBe(true); } }); test("rejects hostBrowser.cdpInspect.probeTimeoutMs above 5000", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { probeTimeoutMs: 10000 } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path .join(".") .includes("hostBrowser.cdpInspect.probeTimeoutMs"), ), ).toBe(true); } }); test("rejects non-integer hostBrowser.cdpInspect.probeTimeoutMs", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { probeTimeoutMs: 500.5 } }, }); expect(result.success).toBe(false); }); test("rejects non-boolean hostBrowser.cdpInspect.enabled", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { enabled: "yes" } }, }); expect(result.success).toBe(false); }); // ── services.tts config ────────────────────────────────────────────── test("applies services.tts defaults when not specified", () => { const result = AssistantConfigSchema.parse({}); expect(result.services.tts.provider).toBe("elevenlabs"); expect(result.services.tts.providers.elevenlabs.voiceId).toBe( DEFAULT_ELEVENLABS_VOICE_ID, ); expect(result.services.tts.providers.elevenlabs.speed).toBe(1.0); expect(result.services.tts.providers.elevenlabs.stability).toBe(0.5); expect(result.services.tts.providers.elevenlabs.similarityBoost).toBe(0.75); expect( result.services.tts.providers.elevenlabs.conversationTimeoutSeconds, ).toBe(30); expect(result.services.tts.providers["fish-audio"].referenceId).toBe(""); expect(result.services.tts.providers["fish-audio"].chunkLength).toBe(200); expect(result.services.tts.providers["fish-audio"].format).toBe("mp3"); expect(result.services.tts.providers["fish-audio"].speed).toBe(1.0); expect(result.services.tts.providers.deepgram.model).toBe( "aura-asteria-en", ); expect(result.services.tts.providers.deepgram.format).toBe("mp3"); }); test("accepts valid services.tts provider override", () => { const result = AssistantConfigSchema.parse({ services: { tts: { provider: "fish-audio" } }, }); expect(result.services.tts.provider).toBe("fish-audio"); }); test("accepts deepgram as services.tts.provider", () => { const result = AssistantConfigSchema.parse({ services: { tts: { provider: "deepgram" } }, }); expect(result.services.tts.provider).toBe("deepgram"); }); test("accepts valid services.tts.providers.elevenlabs overrides", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { elevenlabs: { voiceId: "custom-voice", speed: 0.8 }, }, }, }, }); expect(result.services.tts.providers.elevenlabs.voiceId).toBe( "custom-voice", ); expect(result.services.tts.providers.elevenlabs.speed).toBe(0.8); // Unset fields preserve defaults expect(result.services.tts.providers.elevenlabs.stability).toBe(0.5); }); test("accepts valid services.tts.providers.fish-audio overrides", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { "fish-audio": { referenceId: "my-voice", format: "wav" }, }, }, }, }); expect(result.services.tts.providers["fish-audio"].referenceId).toBe( "my-voice", ); expect(result.services.tts.providers["fish-audio"].format).toBe("wav"); // Defaults preserved expect(result.services.tts.providers["fish-audio"].chunkLength).toBe(200); }); test("accepts valid services.tts.providers.deepgram overrides", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { deepgram: { model: "aura-luna-en", format: "opus" }, }, }, }, }); expect(result.services.tts.providers.deepgram.model).toBe("aura-luna-en"); expect(result.services.tts.providers.deepgram.format).toBe("opus"); }); test("accepts services.tts.providers languageVoices maps", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { elevenlabs: { languageVoices: { hi: "voice-hindi", ja: "voice-japanese" }, }, deepgram: { languageVoices: { hi: "aura-2-hi-voice" } }, vellum: { languageVoices: { hi: "aura-2-hi-voice" } }, }, }, }, }); expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({ hi: "voice-hindi", ja: "voice-japanese", }); expect(result.services.tts.providers.deepgram.languageVoices).toEqual({ hi: "aura-2-hi-voice", }); expect(result.services.tts.providers.vellum.languageVoices).toEqual({ hi: "aura-2-hi-voice", }); }); test("normalizes user-entered languageVoices keys to lowercase base subtags", () => { // "hi-IN" or "HI" typed into config must still match a spoken "hi" // instead of silently never applying; the schema normalizes on parse. const result = AssistantConfigSchema.parse({ services: { tts: { providers: { elevenlabs: { languageVoices: { "hi-IN": "voice-hindi", JA: "voice-japanese", es_419: "voice-spanish", " PT ": "voice-portuguese", }, }, }, }, }, }); expect(result.services.tts.providers.elevenlabs.languageVoices).toEqual({ hi: "voice-hindi", ja: "voice-japanese", es: "voice-spanish", pt: "voice-portuguese", }); }); test("keeps the first entry when languageVoices keys collide after normalization", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { deepgram: { languageVoices: { "hi-IN": "voice-first", hi: "voice-second" }, }, }, }, }, }); expect(result.services.tts.providers.deepgram.languageVoices).toEqual({ hi: "voice-first", }); }); test("drops languageVoices keys that normalize to the empty string", () => { const result = AssistantConfigSchema.parse({ services: { tts: { providers: { vellum: { languageVoices: { "": "voice-empty", " ": "voice-blank", "-IN": "voice-region-only", hi: "voice-hindi", }, }, }, }, }, }); expect(result.services.tts.providers.vellum.languageVoices).toEqual({ hi: "voice-hindi", }); }); test("languageVoices defaults to unset", () => { const result = AssistantConfigSchema.parse({}); expect( result.services.tts.providers.elevenlabs.languageVoices, ).toBeUndefined(); expect( result.services.tts.providers.deepgram.languageVoices, ).toBeUndefined(); expect(result.services.tts.providers.vellum.languageVoices).toBeUndefined(); }); test("rejects non-string services.tts.providers languageVoices values", () => { for (const provider of ["elevenlabs", "deepgram", "vellum"]) { const result = AssistantConfigSchema.safeParse({ services: { tts: { providers: { [provider]: { languageVoices: { hi: 42 } }, }, }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("languageVoices"), ), ).toBe(true); } } }); // Legacy config objects on disk may carry a `mode` key. Parsing must strip // it rather than reject the config — and must not treat it as a managed // selection, which is what migration 130 rewrites to `provider: "vellum"`. test("ignores a legacy tts mode key", () => { const result = AssistantConfigSchema.parse({ services: { tts: { mode: "managed" } }, }); expect(result.services.tts).not.toHaveProperty("mode"); expect(result.services.tts.provider).toBe("elevenlabs"); }); test("accepts tts provider vellum as an ordinary choice", () => { const result = AssistantConfigSchema.safeParse({ services: { tts: { provider: "vellum" } }, }); expect(result.success).toBe(true); }); // ── hostBrowser.cdpInspect.desktopAuto config ─────────────────────── test("applies hostBrowser.cdpInspect.desktopAuto defaults", () => { const result = AssistantConfigSchema.parse({}); expect(result.hostBrowser.cdpInspect.desktopAuto).toEqual({ enabled: true, cooldownMs: 30_000, }); }); test("accepts hostBrowser.cdpInspect.desktopAuto overrides", () => { const result = AssistantConfigSchema.parse({ hostBrowser: { cdpInspect: { desktopAuto: { enabled: false, cooldownMs: 10_000 }, }, }, }); expect(result.hostBrowser.cdpInspect.desktopAuto.enabled).toBe(false); expect(result.hostBrowser.cdpInspect.desktopAuto.cooldownMs).toBe(10_000); }); test("accepts hostBrowser.cdpInspect.desktopAuto.cooldownMs of 0 (disable cooldown)", () => { const result = AssistantConfigSchema.parse({ hostBrowser: { cdpInspect: { desktopAuto: { cooldownMs: 0 } }, }, }); expect(result.hostBrowser.cdpInspect.desktopAuto.cooldownMs).toBe(0); }); test("rejects hostBrowser.cdpInspect.desktopAuto.cooldownMs below 0", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { desktopAuto: { cooldownMs: -1 } }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("cooldownMs"), ), ).toBe(true); } }); test("rejects invalid services.tts.provider", () => { const result = AssistantConfigSchema.safeParse({ services: { tts: { provider: "aws-polly" } }, }); expect(result.success).toBe(false); if (!result.success) { const msgs = result.error.issues.map((i) => i.message); expect(msgs.some((m) => m.includes("services.tts.provider"))).toBe(true); } }); test("services.tts.provider defaults to elevenlabs and rejects unknown ids", () => { const defaulted = TtsServiceSchema.safeParse({}); expect(defaulted.success).toBe(true); if (defaulted.success) { expect(defaulted.data.provider).toBe("elevenlabs"); } expect(TtsServiceSchema.safeParse({ provider: "vellum" }).success).toBe( true, ); expect( TtsServiceSchema.safeParse({ provider: "self-hosted" }).success, ).toBe(false); }); // ── services.stt config ────────────────────────────────────────────── test("rejects services.stt without explicit provider", () => { const result = AssistantConfigSchema.safeParse({ services: { stt: { mode: "your-own" } }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((i) => i.path.join(".").includes("provider")), ).toBe(true); } }); test("applies services.stt structural defaults when provider is explicit", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "openai-whisper" } }, }); expect(result.services.stt.provider).toBe("openai-whisper"); // providers defaults to empty sparse map expect(result.services.stt.providers).toEqual({}); }); test("accepts valid services.stt provider override", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "openai-whisper" } }, }); expect(result.services.stt.provider).toBe("openai-whisper"); }); test("accepts valid services.stt.providers.openai-whisper overrides", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "openai-whisper", providers: { "openai-whisper": {}, }, }, }, }); expect(result.services.stt.providers["openai-whisper"]).toEqual({}); }); test("parses when providers map is empty (sparse default)", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "deepgram", providers: {} } }, }); expect(result.services.stt.providers).toEqual({}); expect(result.services.stt.provider).toBe("deepgram"); }); test("parses when unknown future provider blobs exist under providers", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "openai-whisper", providers: { "openai-whisper": {}, "future-provider": { model: "next-gen", lang: "en" }, }, }, }, }); expect(result.services.stt.providers["openai-whisper"]).toEqual({}); expect(result.services.stt.providers["future-provider"]).toEqual({ model: "next-gen", lang: "en", }); }); // Legacy config objects on disk may carry a `mode` key. Parsing must strip // it rather than reject the config — and must not treat it as a managed // selection, which is what migration 130 rewrites to `provider: "vellum"`. test("ignores a legacy stt mode key", () => { const result = AssistantConfigSchema.parse({ services: { stt: { mode: "managed", provider: "deepgram" } }, }); expect(result.services.stt).not.toHaveProperty("mode"); expect(result.services.stt.provider).toBe("deepgram"); }); test("accepts stt provider vellum as an ordinary choice", () => { const result = AssistantConfigSchema.safeParse({ services: { stt: { provider: "vellum" } }, }); expect(result.success).toBe(true); }); test("rejects invalid services.stt.provider", () => { const result = AssistantConfigSchema.safeParse({ services: { stt: { provider: "azure-speech" } }, }); expect(result.success).toBe(false); if (!result.success) { const msgs = result.error.issues.map((i) => i.message); expect(msgs.some((m) => m.includes("services.stt.provider"))).toBe(true); } }); test("accepts deepgram as services.stt.provider", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "deepgram" } }, }); expect(result.services.stt.provider).toBe("deepgram"); }); test("accepts google-gemini as services.stt.provider", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "google-gemini" } }, }); expect(result.services.stt.provider).toBe("google-gemini"); }); test("applies services.stt structural defaults when google-gemini provider is explicit", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "google-gemini" } }, }); expect(result.services.stt.provider).toBe("google-gemini"); expect(result.services.stt.providers).toEqual({}); }); test("accepts valid services.stt.providers.deepgram overrides", () => { const result = AssistantConfigSchema.parse({ services: { stt: { provider: "deepgram", providers: { deepgram: {}, }, }, }, }); expect(result.services.stt.providers.deepgram).toEqual({}); }); test("existing configs with explicit per-provider objects continue to parse", () => { // Configs with explicit per-provider objects must continue to // parse and round-trip successfully. const result = AssistantConfigSchema.parse({ services: { stt: { provider: "openai-whisper", providers: { "openai-whisper": {}, deepgram: {}, }, }, }, }); expect(result.services.stt.providers["openai-whisper"]).toEqual({}); expect(result.services.stt.providers.deepgram).toEqual({}); }); test("services.stt.provider is required (no implicit default)", () => { const result = AssistantConfigSchema.safeParse({ services: { stt: {} }, }); expect(result.success).toBe(false); }); test("services.stt.provider accepts known ids and rejects unknown ones", () => { expect( SttServiceSchema.safeParse({ provider: "openai-whisper" }).success, ).toBe(true); expect(SttServiceSchema.safeParse({ provider: "vellum" }).success).toBe( true, ); expect( SttServiceSchema.safeParse({ provider: "self-hosted" }).success, ).toBe(false); }); test("rejects hostBrowser.cdpInspect.desktopAuto.cooldownMs above 300000", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { desktopAuto: { cooldownMs: 500_000 } }, }, }); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue) => issue.path.join(".").includes("cooldownMs"), ), ).toBe(true); } }); test("rejects non-integer hostBrowser.cdpInspect.desktopAuto.cooldownMs", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { desktopAuto: { cooldownMs: 5000.5 } }, }, }); expect(result.success).toBe(false); }); test("rejects non-boolean hostBrowser.cdpInspect.desktopAuto.enabled", () => { const result = AssistantConfigSchema.safeParse({ hostBrowser: { cdpInspect: { desktopAuto: { enabled: "yes" } }, }, }); expect(result.success).toBe(false); }); test("desktopAuto defaults preserved when only cdpInspect.enabled is set", () => { const result = AssistantConfigSchema.parse({ hostBrowser: { cdpInspect: { enabled: true } }, }); expect(result.hostBrowser.cdpInspect.desktopAuto).toEqual({ enabled: true, cooldownMs: 30_000, }); }); }); // --------------------------------------------------------------------------- // Tests: TTS config resolver // --------------------------------------------------------------------------- describe("resolveTtsConfig", () => { test("returns default provider and config from empty config", () => { const config = AssistantConfigSchema.parse({}); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("elevenlabs"); expect(resolved.providerConfig).toMatchObject({ voiceId: DEFAULT_ELEVENLABS_VOICE_ID, speed: 1.0, stability: 0.5, similarityBoost: 0.75, }); }); test("uses canonical services.tts.provider when set", () => { const config = AssistantConfigSchema.parse({ services: { tts: { provider: "fish-audio" } }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("fish-audio"); expect(resolved.providerConfig).toMatchObject({ referenceId: "", chunkLength: 200, format: "mp3", speed: 1.0, }); }); test("returns canonical elevenlabs config from services.tts.providers", () => { const config = AssistantConfigSchema.parse({ services: { tts: { provider: "elevenlabs", providers: { elevenlabs: { voiceId: "canonical-voice", stability: 0.9 }, }, }, }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("elevenlabs"); expect(resolved.providerConfig).toMatchObject({ voiceId: "canonical-voice", stability: 0.9, }); }); test("uses canonical elevenlabs config exclusively (no legacy fallback)", () => { const config = AssistantConfigSchema.parse({ services: { tts: { providers: { elevenlabs: { voiceId: "canonical-voice", speed: 0.9 }, }, }, }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("elevenlabs"); expect(resolved.providerConfig).toMatchObject({ voiceId: "canonical-voice", speed: 0.9, }); }); test("uses canonical fish-audio config exclusively (no legacy fallback)", () => { const config = AssistantConfigSchema.parse({ services: { tts: { provider: "fish-audio", providers: { "fish-audio": { referenceId: "canonical-ref", format: "wav" }, }, }, }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("fish-audio"); expect(resolved.providerConfig).toMatchObject({ referenceId: "canonical-ref", format: "wav", }); }); test("returns empty config for unknown provider", () => { // Force an unknown provider via type assertion for coverage. // structuredClone prevents mutation from leaking into Zod's shared // default objects (Zod 4 stores defaults by reference). const config = structuredClone( AssistantConfigSchema.parse({}), ) as AssistantConfig; (config.services.tts as { provider: string }).provider = "aws-polly"; const resolved = resolveTtsConfig(config); expect(resolved.provider as string).toBe("aws-polly"); expect(resolved.providerConfig).toEqual({}); }); test("unknown provider resolution is deterministic across repeated calls", () => { const config = structuredClone( AssistantConfigSchema.parse({}), ) as AssistantConfig; (config.services.tts as { provider: string }).provider = "nonexistent"; const first = resolveTtsConfig(config); const second = resolveTtsConfig(config); expect(first).toEqual(second); expect(first.providerConfig).toEqual({}); }); }); // --------------------------------------------------------------------------- // Tests: TTS provider catalog integration // --------------------------------------------------------------------------- describe("TTS provider catalog integration", () => { test("TTS_PROVIDER_IDS matches the display catalog exactly", () => { expect([...listCatalogProviderIds()].sort()).toEqual( [...TTS_PROVIDER_IDS].sort(), ); }); test("schema accepts all catalog provider IDs as services.tts.provider", () => { for (const providerId of listCatalogProviderIds()) { const result = AssistantConfigSchema.safeParse({ services: { tts: { provider: providerId } }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.services.tts.provider).toBe(providerId); } } }); test("TtsProvidersSchema has a key for every catalog provider", () => { const parsed = AssistantConfigSchema.parse({}); const providerKeys = Object.keys(parsed.services.tts.providers); for (const providerId of listCatalogProviderIds()) { expect(providerKeys).toContain(providerId); } }); test("resolveTtsConfig returns correct defaults for each catalog provider", () => { for (const providerId of listCatalogProviderIds()) { // vellum is connection-based and only valid under managed mode. const mode = providerId === "vellum" ? "managed" : "your-own"; const config = AssistantConfigSchema.parse({ services: { tts: { mode, provider: providerId } }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe(providerId); if (providerId === "vellum") { // The vellum config block is intentionally empty — the platform // pins the voice and model. expect(resolved.providerConfig).toEqual({}); } else { // Every BYOK provider resolves to a non-empty config object. expect(Object.keys(resolved.providerConfig).length).toBeGreaterThan(0); } } }); test("resolveTtsConfig returns overridden values for elevenlabs", () => { const config = AssistantConfigSchema.parse({ services: { tts: { provider: "elevenlabs", providers: { elevenlabs: { voiceId: "override-voice", speed: 0.7 }, }, }, }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("elevenlabs"); expect(resolved.providerConfig).toMatchObject({ voiceId: "override-voice", speed: 0.7, // Defaults still present for unset fields stability: 0.5, similarityBoost: 0.75, }); }); test("resolveTtsConfig returns overridden values for fish-audio", () => { const config = AssistantConfigSchema.parse({ services: { tts: { provider: "fish-audio", providers: { "fish-audio": { referenceId: "override-ref", format: "opus", speed: 1.5, }, }, }, }, }); const resolved = resolveTtsConfig(config); expect(resolved.provider).toBe("fish-audio"); expect(resolved.providerConfig).toMatchObject({ referenceId: "override-ref", format: "opus", speed: 1.5, // Defaults for unset fields chunkLength: 200, }); }); }); // --------------------------------------------------------------------------- // Tests: TTS migration 032 // --------------------------------------------------------------------------- describe("032-tts-provider-unification migration", () => { const migrationDir = join(WORKSPACE_DIR, "_mig032"); beforeEach(() => { if (existsSync(migrationDir)) { rmSync(migrationDir, { recursive: true, force: true }); } mkdirSync(migrationDir, { recursive: true }); }); afterEach(() => { if (existsSync(migrationDir)) { rmSync(migrationDir, { recursive: true, force: true }); } }); function writeMigConfig(obj: unknown): void { writeFileSync( join(migrationDir, "config.json"), JSON.stringify(obj, null, 2), ); } function readMigConfig(): Record { return JSON.parse( readFileSync(join(migrationDir, "config.json"), "utf-8"), ) as Record; } test("backfills provider from calls.voice.ttsProvider", async () => { writeMigConfig({ calls: { voice: { ttsProvider: "fish-audio" } }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); const tts = (result.services as Record).tts as Record< string, unknown >; expect(tts.provider).toBe("fish-audio"); expect(tts.mode).toBe("your-own"); }); test("backfills elevenlabs provider config from legacy keys", async () => { writeMigConfig({ calls: { voice: { ttsProvider: "elevenlabs" } }, elevenlabs: { voiceId: "my-voice", speed: 0.8 }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); const tts = (result.services as Record).tts as Record< string, unknown >; const providers = tts.providers as Record>; expect(providers.elevenlabs.voiceId).toBe("my-voice"); expect(providers.elevenlabs.speed).toBe(0.8); }); test("backfills fish-audio provider config from legacy keys", async () => { writeMigConfig({ calls: { voice: { ttsProvider: "fish-audio" } }, fishAudio: { referenceId: "my-ref", format: "wav" }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); const tts = (result.services as Record).tts as Record< string, unknown >; const providers = tts.providers as Record>; expect(providers["fish-audio"].referenceId).toBe("my-ref"); expect(providers["fish-audio"].format).toBe("wav"); }); test("removes legacy fields after migration", async () => { writeMigConfig({ calls: { voice: { ttsProvider: "elevenlabs", language: "en-US" } }, elevenlabs: { voiceId: "my-voice" }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); // Legacy keys removed expect( ( (result.calls as Record).voice as Record< string, unknown > ).ttsProvider, ).toBeUndefined(); expect(result.elevenlabs).toBeUndefined(); // Other voice fields preserved expect( ( (result.calls as Record).voice as Record< string, unknown > ).language, ).toBe("en-US"); }); test("is idempotent — repeated runs produce no changes", async () => { writeMigConfig({ calls: { voice: { ttsProvider: "fish-audio" } }, fishAudio: { referenceId: "my-ref" }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const afterFirst = readMigConfig(); await ttsProviderUnificationMigration.run(migrationDir); const afterSecond = readMigConfig(); expect(afterSecond).toEqual(afterFirst); }); test("does not overwrite existing services.tts.provider", async () => { writeMigConfig({ services: { tts: { provider: "elevenlabs" } }, calls: { voice: { ttsProvider: "fish-audio" } }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); const tts = (result.services as Record).tts as Record< string, unknown >; // Should keep the existing canonical value, not the legacy one expect(tts.provider).toBe("elevenlabs"); }); test("does not overwrite existing canonical provider config keys", async () => { writeMigConfig({ services: { tts: { providers: { elevenlabs: { voiceId: "canonical-voice" }, }, }, }, elevenlabs: { voiceId: "legacy-voice", speed: 0.8 }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.run(migrationDir); const result = readMigConfig(); const tts = (result.services as Record).tts as Record< string, unknown >; const providers = tts.providers as Record>; // Canonical voiceId preserved, legacy speed backfilled expect(providers.elevenlabs.voiceId).toBe("canonical-voice"); expect(providers.elevenlabs.speed).toBe(0.8); // Legacy top-level key removed expect(result.elevenlabs).toBeUndefined(); }); test("skips config without any legacy TTS fields", async () => { writeMigConfig({ maxTokens: 4096 }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); const before = readMigConfig(); await ttsProviderUnificationMigration.run(migrationDir); const after = readMigConfig(); // Should remain unchanged (no services.tts added) expect(after).toEqual(before); }); test("down removes services.tts from config", async () => { writeMigConfig({ services: { inference: { provider: "anthropic" }, tts: { provider: "elevenlabs", mode: "your-own" }, }, }); const { ttsProviderUnificationMigration } = await import("../workspace/migrations/032-tts-provider-unification.js"); await ttsProviderUnificationMigration.down(migrationDir); const result = readMigConfig(); const services = result.services as Record; expect(services.tts).toBeUndefined(); // Other services keys preserved expect(services.inference).toBeDefined(); }); }); // --------------------------------------------------------------------------- // Tests: loader integration (config file -> loadConfig with fallback) // --------------------------------------------------------------------------- describe("loadConfig with schema validation", () => { beforeEach(() => { // Keep WORKSPACE_DIR and logs in place to avoid racing async logger stream init. ensureTestDir(); const resetPaths = [ CONFIG_PATH, join(WORKSPACE_DIR, "keys.enc"), join(WORKSPACE_DIR, "data"), join(WORKSPACE_DIR, "data", "memory"), ]; for (const path of resetPaths) { if (existsSync(path)) { rmSync(path, { recursive: true, force: true }); } } ensureTestDir(); setStorePathForTesting(join(WORKSPACE_DIR, "keys.enc")); invalidateConfigCache(); }); afterEach(() => { setStorePathForTesting(null); invalidateConfigCache(); }); // Intentionally do not remove WORKSPACE_DIR in afterAll. // A late async logger flush may still target logs under this path and can // intermittently trigger unhandled ENOENT in CI if the directory is removed. test("loads valid config", () => { writeConfig({ llm: { callSites: { mainAgent: { provider: "openai", model: "gpt-4", maxTokens: 4096 }, }, }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.provider).toBe("openai"); expect(config.llm.callSites?.mainAgent?.model).toBe("gpt-4"); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); }); test("applies defaults for missing fields", () => { writeConfig({}); const config = loadConfig(); expect(config.llm.profiles).toEqual({}); expect(config.llm.profileOrder).toEqual([]); expect(config.llm.callSites).toEqual({}); expect(config.llm.profileSession).toEqual({ defaultTtlSeconds: 1800, maxTtlSeconds: 43200, }); }); test("legacy llm.default blob loads without error and is ignored", () => { // Load-time backwards compat: old configs still carrying `llm.default` // load cleanly — the key is stripped from the parsed config (not an // error, no fallback to full defaults) and sibling llm fields survive. writeConfig({ llm: { default: { provider: "openai", model: "gpt-4" }, callSites: { mainAgent: { maxTokens: 4096 } }, }, }); const config = loadConfig(); expect((config.llm as Record).default).toBeUndefined(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); }); test("keeps an unknown provider on load (read tolerance for entry names)", () => { // The open provider schema keeps the whole profile: an unknown value is // resolved as a connection entry name at dispatch (or fails there // explainably) rather than being leaf-stripped on read. writeConfig({ llm: { profiles: { custom: { provider: "unknown-provider", model: "gpt-4" } }, }, }); const config = loadConfig(); expect(config.llm.profiles.custom?.provider).toBe("unknown-provider"); expect(config.llm.profiles.custom?.model).toBe("gpt-4"); }); test("falls back to default for invalid maxTokens", () => { writeConfig({ llm: { callSites: { mainAgent: { maxTokens: -100, model: "gpt-4" } } }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBeUndefined(); expect(config.llm.callSites?.mainAgent?.model).toBe("gpt-4"); }); test("falls back to defaults for invalid nested values", () => { writeConfig({ timeouts: { shellDefaultTimeoutSec: -5, shellMaxTimeoutSec: "bad" }, }); const config = loadConfig(); expect(config.timeouts.shellDefaultTimeoutSec).toBe(120); expect(config.timeouts.shellMaxTimeoutSec).toBe(600); expect(config.timeouts.permissionTimeoutSec).toBe(300); }); test("preserves valid fields when other fields are invalid", () => { writeConfig({ llm: { callSites: { mainAgent: { provider: "openai", model: "gpt-4", maxTokens: -1, thinking: { enabled: true }, }, }, }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.provider).toBe("openai"); expect(config.llm.callSites?.mainAgent?.model).toBe("gpt-4"); expect(config.llm.callSites?.mainAgent?.thinking?.enabled).toBe(true); expect(config.llm.callSites?.mainAgent?.maxTokens).toBeUndefined(); }); test("handles no config file", () => { const config = loadConfig(); expect(config.llm.profiles).toEqual({}); expect(config.llm.profileSession.defaultTtlSeconds).toBe(1800); }); test("partial nested objects get defaults for missing fields", () => { writeConfig({ timeouts: { shellDefaultTimeoutSec: 30 }, }); const config = loadConfig(); expect(config.timeouts.shellDefaultTimeoutSec).toBe(30); expect(config.timeouts.shellMaxTimeoutSec).toBe(600); expect(config.timeouts.permissionTimeoutSec).toBe(300); }); test("falls back for out-of-range contextWindow ratio", () => { // Leaf-deletion recovery: the invalid ratio leaf is stripped while its // valid sibling survives. writeConfig({ llm: { callSites: { mainAgent: { contextWindow: { targetBudgetRatio: 1.5, compactThreshold: 0.7 }, }, }, }, }); const config = loadConfig(); expect( config.llm.callSites?.mainAgent?.contextWindow?.targetBudgetRatio, ).toBeUndefined(); expect( config.llm.callSites?.mainAgent?.contextWindow?.compactThreshold, ).toBe(0.7); }); test("falls back for invalid rateLimit values", () => { writeConfig({ rateLimit: { maxRequestsPerMinute: -1 }, }); const config = loadConfig(); expect(config.rateLimit.maxRequestsPerMinute).toBe(0); }); test("falls back for invalid auditLog.retentionDays", () => { writeConfig({ auditLog: { retentionDays: -7 } }); const config = loadConfig(); expect(config.auditLog.retentionDays).toBe(0); }); // ── Calls config (loader integration) ────────────────────────────── test("loads calls config from file", () => { writeConfig({ calls: { enabled: false, maxDurationSeconds: 600 }, }); const config = loadConfig(); expect(config.calls.enabled).toBe(false); expect(config.calls.maxDurationSeconds).toBe(600); expect(config.calls.userConsultTimeoutSeconds).toBe(120); expect(config.calls.provider).toBe("twilio"); }); test("falls back for invalid calls.provider", () => { writeConfig({ calls: { provider: "vonage" } }); const config = loadConfig(); expect(config.calls.provider).toBe("twilio"); }); test("recovers from partial filing.activeHours without wiping unrelated fields", () => { // Only activeHoursStart is set. The superRefine must emit the issue so // the loader's delete-and-retry can strip the set field; otherwise the // mismatch persists and the config falls back to full defaults (which // would wipe the user's llm.callSites.mainAgent.maxTokens below). writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 4096 } } }, filing: { activeHoursStart: 8 }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(config.filing.activeHoursStart).toBeNull(); expect(config.filing.activeHoursEnd).toBeNull(); }); test("recovers from partial heartbeat.activeHours without wiping unrelated fields", () => { // activeHoursStart is explicitly nulled while activeHoursEnd defaults to // 22 — a mismatch. Dual-emit strips both sides; both defaults restore // (8, 22). llm.callSites.mainAgent.maxTokens is unaffected. writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 4096 } } }, heartbeat: { activeHoursStart: null }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(config.heartbeat.activeHoursStart).toBe(8); expect(config.heartbeat.activeHoursEnd).toBe(22); }); test("recovers from heartbeat.activeHours null-mismatch where explicit value equals opposite default", () => { // { start: null, end: 8 } — single-emit on the null side would strip // start, the default 8 would restore it, and the equal-hours check would // fire, cascading to a full defaults reset that wipes the user's // llm.callSites.mainAgent.maxTokens. Dual-emit strips both sides in one // pass. writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 4096 } } }, heartbeat: { activeHoursStart: null, activeHoursEnd: 8 }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(config.heartbeat.activeHoursStart).toBe(8); expect(config.heartbeat.activeHoursEnd).toBe(22); }); test("recovers from heartbeat.activeHours null-mismatch on the end side", () => { // { start: 22, end: null } — same cascade class as above, mirrored. writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 4096 } } }, heartbeat: { activeHoursStart: 22, activeHoursEnd: null }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(config.heartbeat.activeHoursStart).toBe(8); expect(config.heartbeat.activeHoursEnd).toBe(22); }); test("recovers from equal heartbeat.activeHours without wiping unrelated fields", () => { // { start: 22, end: 22 } — both equal to the default for end. Single-emit // on one path would strip one side, the default would recreate the // equal-hours mismatch, and the loader would fall back to full defaults, // wiping the user's llm.callSites.mainAgent.maxTokens. Dual-emit strips // both sides at once. writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 4096 } } }, heartbeat: { activeHoursStart: 22, activeHoursEnd: 22 }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(4096); expect(config.heartbeat.activeHoursStart).toBe(8); expect(config.heartbeat.activeHoursEnd).toBe(22); }); test("recovers from equal filing.activeHours without wiping unrelated fields", () => { // activeHoursStart === activeHoursEnd is invalid (empty window). Filing's // defaults are null/null, so single-emit on one path would strip one side // and the null default would recreate a mismatch — cascading to a full // defaults reset that wipes the user's llm.callSites.mainAgent.maxTokens. // Dual-emit strips both sides so both defaults restore to null. writeConfig({ llm: { callSites: { mainAgent: { maxTokens: 1234 } } }, filing: { activeHoursStart: 5, activeHoursEnd: 5 }, }); const config = loadConfig(); expect(config.llm.callSites?.mainAgent?.maxTokens).toBe(1234); expect(config.filing.activeHoursStart).toBeNull(); expect(config.filing.activeHoursEnd).toBeNull(); }); test("applies calls defaults when not specified", () => { writeConfig({}); const config = loadConfig(); expect(config.calls.enabled).toBe(true); expect(config.calls.maxDurationSeconds).toBe(3600); expect(config.calls.userConsultTimeoutSeconds).toBe(120); expect(config.calls.disclosure.enabled).toBe(true); expect(config.calls.safety.denyCategories).toEqual([]); expect( (config.calls.voice as Record).language, ).toBeUndefined(); expect( (config.calls.voice as Record).transcriptionProvider, ).toBeUndefined(); expect( (config.calls.voice as Record).ttsProvider, ).toBeUndefined(); expect((config.calls as Record).model).toBeUndefined(); expect(config.calls.callerIdentity).toEqual({ allowPerCallOverride: true, }); }); }); // --------------------------------------------------------------------------- // Tests: Call entrypoint gating // --------------------------------------------------------------------------- describe("Call entrypoint gating", () => { beforeEach(() => { ensureTestDir(); const resetPaths = [ CONFIG_PATH, join(WORKSPACE_DIR, "keys.enc"), join(WORKSPACE_DIR, "data"), join(WORKSPACE_DIR, "data", "memory"), ]; for (const path of resetPaths) { if (existsSync(path)) { rmSync(path, { recursive: true, force: true }); } } ensureTestDir(); setStorePathForTesting(join(WORKSPACE_DIR, "keys.enc")); invalidateConfigCache(); }); afterEach(() => { setStorePathForTesting(null); invalidateConfigCache(); }); test("call_start tool returns error when calls.enabled is false", async () => { writeConfig({ calls: { enabled: false } }); // Force config reload loadConfig(); const { executeCallStart: _executeCallStart } = await import("../tools/calls/call-start.js"); // The tool is registered via side effect. We need to test the gating logic directly. // Since the module registers itself, we test by loading config and checking behavior. const { getConfig } = await import("../config/loader.js"); const config = getConfig(); expect(config.calls.enabled).toBe(false); }); test("calls_start route throws ForbiddenError when calls.enabled is false", async () => { writeConfig({ calls: { enabled: false } }); loadConfig(); const { ROUTES } = await import("../runtime/routes/call-routes.js"); const { RouteError } = await import("../runtime/routes/errors.js"); const startRoute = ROUTES.find((r) => r.operationId === "calls_start"); expect(startRoute).toBeDefined(); try { await startRoute!.handler({ body: { phoneNumber: "+14155551234", task: "Test call", conversationId: "test-conv-id", }, }); throw new Error("Expected handler to throw"); } catch (err) { expect(err).toBeInstanceOf(RouteError); const routeErr = err as InstanceType; expect(routeErr.statusCode).toBe(403); expect(routeErr.message).toContain("disabled"); } }); });