diff --git a/src/resources/extensions/sf/code-intelligence.ts b/src/resources/extensions/sf/code-intelligence.ts index 2a35eb69f..d37c18ecb 100644 --- a/src/resources/extensions/sf/code-intelligence.ts +++ b/src/resources/extensions/sf/code-intelligence.ts @@ -141,7 +141,7 @@ const DEFAULT_SIFT_WARMUP_QUERY = "repo architecture source tests entrypoints configuration"; const DEFAULT_SIFT_WARMUP_LIMIT = 1; const DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS = 30_000; -const DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC = 1800; +const DEFAULT_SIFT_WARMUP_HARD_TIMEOUT_SEC = 30; const SIFT_WARMUP_KILL_GRACE_SEC = 10; function readJsonConfig(configPath: string): McpConfigFile { @@ -463,8 +463,7 @@ export function ensureSiftIndexWarmup( String(options.limit ?? DEFAULT_SIFT_WARMUP_LIMIT), "--retriever-timeout-ms", String( - options.retrieverTimeoutMs ?? - DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS, + options.retrieverTimeoutMs ?? DEFAULT_SIFT_WARMUP_RETRIEVER_TIMEOUT_MS, ), ".", options.query ?? DEFAULT_SIFT_WARMUP_QUERY, diff --git a/src/resources/extensions/sf/tests/code-intelligence.test.ts b/src/resources/extensions/sf/tests/code-intelligence.test.ts index e3bc430d8..abc31c052 100644 --- a/src/resources/extensions/sf/tests/code-intelligence.test.ts +++ b/src/resources/extensions/sf/tests/code-intelligence.test.ts @@ -8,7 +8,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { test } from 'vitest'; +import { test } from "vitest"; import { buildCodeIntelligenceContextBlock, @@ -314,11 +314,9 @@ test("effective codebase indexer uses project-rag only when explicitly selected" "projectRag", ); assert.equal( - resolveEffectiveCodebaseIndexerBackendName( - projectRoot, - undefined, - { PATH: join(projectRoot, "bin") }, - ), + resolveEffectiveCodebaseIndexerBackendName(projectRoot, undefined, { + PATH: join(projectRoot, "bin"), + }), "sift", ); } finally { @@ -332,11 +330,9 @@ test("effective codebase indexer defaults to sift when project-rag is absent", ( writeFakeSiftBinary(projectRoot); assert.equal( - resolveEffectiveCodebaseIndexerBackendName( - projectRoot, - undefined, - { PATH: join(projectRoot, "bin") }, - ), + resolveEffectiveCodebaseIndexerBackendName(projectRoot, undefined, { + PATH: join(projectRoot, "bin"), + }), "sift", ); assert.match( @@ -355,7 +351,11 @@ test("ensureSiftIndexWarmup starts page-index-hybrid warmup and writes marker", try { const fakeSift = writeFakeSiftBinary(projectRoot); const calls: Array<{ command: string; args: string[]; cwd?: string }> = []; - const fakeSpawn = ((command: string, args: string[], options?: { cwd?: string }) => { + const fakeSpawn = (( + command: string, + args: string[], + options?: { cwd?: string }, + ) => { calls.push({ command, args, cwd: options?.cwd }); return { unref() {} }; }) as unknown as typeof import("node:child_process").spawn; @@ -492,6 +492,39 @@ test("ensureSiftIndexWarmup wraps sift with timeout(1) when available", () => { } }); +test("ensureSiftIndexWarmup defaults optional warmup hard cap to 30 seconds", () => { + const projectRoot = makeProject(); + try { + const fakeSift = writeFakeSiftBinary(projectRoot); + const fakeTimeout = join(projectRoot, "bin", "timeout"); + writeFileSync(fakeTimeout, "", "utf-8"); + + const calls: Array<{ command: string; args: string[] }> = []; + const fakeSpawn = ((command: string, args: string[]) => { + calls.push({ command, args }); + return { unref() {} }; + }) as unknown as typeof import("node:child_process").spawn; + + const result = ensureSiftIndexWarmup(projectRoot, undefined, { + env: { PATH: join(projectRoot, "bin") }, + spawnFn: fakeSpawn, + force: true, + now: Date.parse("2026-05-02T12:00:00.000Z"), + }); + + assert.equal(result.status, "started"); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].args.slice(0, 3), [ + "--kill-after=10", + "30", + fakeSift, + ]); + assert.match(result.reason, /hard cap 30s/); + } finally { + cleanup(projectRoot); + } +}); + test("ensureSiftIndexWarmup honors SF_SIFT_HARD_TIMEOUT_DISABLE", () => { const projectRoot = makeProject(); try { diff --git a/src/tests/search-minimax.test.ts b/src/tests/search-minimax.test.ts new file mode 100644 index 000000000..ef1f380cc --- /dev/null +++ b/src/tests/search-minimax.test.ts @@ -0,0 +1,860 @@ +/** + * Contract tests for MiniMax search integration in tool-search.ts. + * + * Covers: + * - getMiniMaxSearchApiKey: priority order (MINIMAX_CODE_PLAN_KEY > MINIMAX_CODING_API_KEY > MINIMAX_API_KEY) + * - resolveSearchProvider: returns "minimax" when minimax key is set + * - /search-provider minimax command: sets preference and notifies + * - executeMiniMaxSearch: POST request construction, response mapping, deduplication + * - executeMiniMaxSearch: error handling for base_resp status_code + * - Missing key: no-key error message mentions MINIMAX_CODE_PLAN_KEY + */ + +import assert from "node:assert/strict"; +import { afterEach, test } from "vitest"; + +import { + getMiniMaxSearchApiKey, + resolveSearchProvider, +} from "../resources/extensions/search-the-web/provider.ts"; +import { normalizeHeaders, parseJsonBody } from "./fetch-test-helpers.ts"; + +// ============================================================================= +// Helpers for mocking global fetch +// ============================================================================= + +/** A minimal MiniMax API response fixture. */ +function makeMiniMaxResponse(overrides: Record = {}) { + return { + organic: [ + { + title: "First MiniMax Result", + link: "https://example.com/minimax-first", + snippet: "Description of first minimax result.", + date: "2025-11-15", + }, + { + title: "Second MiniMax Result", + link: "https://example.com/minimax-second", + snippet: "Description of second minimax result.", + }, + ], + related_searches: [{ query: "related query" }], + base_resp: { status_code: 0, status_msg: "success" }, + ...overrides, + }; +} + +/** + * Install a mock global fetch that captures request details and returns a + * MiniMax response fixture. Returns an object with the captured request info. + */ +function mockFetch(responseBody: unknown, status = 200) { + const captured: { + url?: string; + method?: string; + headers?: Record; + body?: Record; + } = {}; + + const originalFetch = globalThis.fetch; + + globalThis.fetch = async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + captured.url = url; + captured.method = init?.method ?? "GET"; + captured.headers = normalizeHeaders(init?.headers); + captured.body = parseJsonBody(init?.body); + + return new Response(JSON.stringify(responseBody), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; + + const restore = () => { + globalThis.fetch = originalFetch; + }; + return { captured, restore }; +} + +// ─── Helpers (reused from search-provider-command.test.ts pattern) ──────────────────────── + +const SEARCH_ENV_KEYS = [ + "TAVILY_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CODE_PLAN_KEY", + "MINIMAX_CODING_API_KEY", + "BRAVE_API_KEY", + "SERPER_API_KEY", + "EXA_API_KEY", + "OLLAMA_API_KEY", +] as const; + +async function withEnv( + vars: Record, + fn: () => T | Promise, +): Promise { + const originals: Record = {}; + const keys = new Set([...SEARCH_ENV_KEYS, ...Object.keys(vars)]); + for (const key of keys) { + originals[key] = process.env[key]; + const value = vars[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return await fn(); + } finally { + for (const key of Object.keys(originals)) { + if (originals[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originals[key]; + } + } + } +} + +// ─── Mock command context ────────────────────────────────────────────────── + +interface MockCtx { + ui: { + select: (title: string, options: string[]) => Promise; + notify: (message: string, type?: string) => void; + selectCalls: Array<{ title: string; options: string[] }>; + notifyCalls: Array<{ message: string; type?: string }>; + selectReturn: string | undefined; + }; + cwd: string; +} + +function makeMockCtx(selectReturn?: string): MockCtx { + const ctx: MockCtx = { + ui: { + selectCalls: [], + notifyCalls: [], + selectReturn, + async select(title: string, options: string[]) { + ctx.ui.selectCalls.push({ title, options }); + return ctx.ui.selectReturn; + }, + notify(message: string, type?: string) { + ctx.ui.notifyCalls.push({ message, type }); + }, + }, + cwd: "/tmp", + }; + return ctx; +} + +async function loadCommand(): Promise<{ + name: string; + description?: string; + getArgumentCompletions?: (prefix: string) => any; + handler: (args: string, ctx: any) => Promise; +}> { + const { registerSearchProviderCommand } = await import( + "../resources/extensions/search-the-web/command-search-provider.ts" + ); + + let captured: any; + const mockPi = { + registerCommand(name: string, options: any) { + captured = { name, ...options }; + }, + }; + + registerSearchProviderCommand(mockPi as any); + assert.ok( + captured, + "registerSearchProviderCommand should register a command", + ); + assert.equal(captured!.name, "search-provider"); + return captured!; +} + +// ============================================================================= +// 1. getMiniMaxSearchApiKey — priority order +// ============================================================================= + +test("getMiniMaxSearchApiKey prefers MINIMAX_CODE_PLAN_KEY over MINIMAX_CODING_API_KEY", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: "coding-key-456", + MINIMAX_API_KEY: "fallback-key-789", + }, + () => { + const key = getMiniMaxSearchApiKey(); + assert.equal(key, "plan-key-123", "should prefer MINIMAX_CODE_PLAN_KEY"); + }, + ); +}); + +test("getMiniMaxSearchApiKey falls back to MINIMAX_CODING_API_KEY when MINIMAX_CODE_PLAN_KEY is absent", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: undefined, + MINIMAX_CODING_API_KEY: "coding-key-456", + MINIMAX_API_KEY: "fallback-key-789", + }, + () => { + const key = getMiniMaxSearchApiKey(); + assert.equal( + key, + "coding-key-456", + "should fall back to MINIMAX_CODING_API_KEY", + ); + }, + ); +}); + +test("getMiniMaxSearchApiKey falls back to MINIMAX_API_KEY when other keys are absent", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: undefined, + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: "fallback-key-789", + }, + () => { + const key = getMiniMaxSearchApiKey(); + assert.equal( + key, + "fallback-key-789", + "should fall back to MINIMAX_API_KEY", + ); + }, + ); +}); + +test("getMiniMaxSearchApiKey returns empty string when TAVILY_API_KEY is explicitly empty", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: undefined, + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: "fallback-key-789", + TAVILY_API_KEY: "", + }, + () => { + const key = getMiniMaxSearchApiKey(); + assert.equal( + key, + "", + "should return empty string when TAVILY_API_KEY is explicitly empty", + ); + }, + ); +}); + +test("getMiniMaxSearchApiKey returns empty string when no keys are set", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: undefined, + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + }, + () => { + const key = getMiniMaxSearchApiKey(); + assert.equal(key, "", "should return empty string when no keys are set"); + }, + ); +}); + +// ============================================================================= +// 2. resolveSearchProvider — minimax path +// ============================================================================= + +test("resolveSearchProvider returns 'minimax' when only MINIMAX_CODE_PLAN_KEY is set", async () => { + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + SERPER_API_KEY: undefined, + EXA_API_KEY: undefined, + OLLAMA_API_KEY: undefined, + }, + () => { + const result = resolveSearchProvider("auto"); + assert.equal(result, "minimax"); + }, + ); +}); + +test("resolveSearchProvider returns 'minimax' when preference is minimax and key exists", async () => { + await withEnv( + { + TAVILY_API_KEY: "tvly-test", + BRAVE_API_KEY: "BSA-test", + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + SERPER_API_KEY: undefined, + EXA_API_KEY: undefined, + OLLAMA_API_KEY: undefined, + }, + () => { + const result = resolveSearchProvider("minimax"); + assert.equal(result, "minimax"); + }, + ); +}); + +test("resolveSearchProvider falls back to tavily when preference is minimax but no minimax key", async () => { + await withEnv( + { + TAVILY_API_KEY: "tvly-test", + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: undefined, + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + SERPER_API_KEY: undefined, + EXA_API_KEY: undefined, + OLLAMA_API_KEY: undefined, + }, + () => { + const result = resolveSearchProvider("minimax"); + assert.equal( + result, + "tavily", + "should fall back to tavily when minimax key missing", + ); + }, + ); +}); + +test("resolveSearchProvider auto-order: tavily > brave > serper > exa > ollama > minimax", async () => { + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + SERPER_API_KEY: undefined, + EXA_API_KEY: undefined, + OLLAMA_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + }, + () => { + const result = resolveSearchProvider("auto"); + assert.equal(result, "minimax", "minimax should be last in auto order"); + }, + ); +}); + +// ============================================================================= +// 3. /search-provider minimax command +// ============================================================================= + +test('direct arg "minimax" sets preference and notifies', async () => { + const cmd = await loadCommand(); + + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + }, + async () => { + const ctx = makeMockCtx(); + await cmd.handler("minimax", ctx); + + assert.equal( + ctx.ui.selectCalls.length, + 0, + "should not show select UI for direct arg", + ); + assert.equal(ctx.ui.notifyCalls.length, 1, "should notify once"); + assert.match( + ctx.ui.notifyCalls[0].message, + /Search provider set to minimax/, + "notification should confirm provider set", + ); + assert.match( + ctx.ui.notifyCalls[0].message, + /Effective provider: minimax/, + "notification should show effective provider", + ); + }, + ); +}); + +test('tab completion includes "minimax" option', async () => { + const cmd = await loadCommand(); + + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + () => { + const items = cmd.getArgumentCompletions!(""); + assert.ok(items, "completions should not be null"); + const values = items!.map((i: any) => i.value); + assert.ok( + values.includes("minimax"), + "completions should include minimax", + ); + }, + ); +}); + +test('tab completion filters to "minimax" with prefix "min"', async () => { + const cmd = await loadCommand(); + + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + () => { + const items = cmd.getArgumentCompletions!("min"); + assert.ok(items); + assert.equal(items!.length, 1); + assert.equal(items![0].value, "minimax"); + }, + ); +}); + +test("select options show minimax key status", async () => { + const cmd = await loadCommand(); + + await withEnv( + { + TAVILY_API_KEY: undefined, + BRAVE_API_KEY: undefined, + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + MINIMAX_CODING_API_KEY: undefined, + MINIMAX_API_KEY: undefined, + }, + async () => { + const ctx = makeMockCtx("minimax (key: ✓)"); + await cmd.handler("", ctx); + + assert.equal(ctx.ui.selectCalls.length, 1); + assert.match(ctx.ui.selectCalls[0].options[1], /minimax \(key: ✓\)/); + }, + ); +}); + +// ============================================================================= +// 4. executeMiniMaxSearch — request shape and response mapping +// ============================================================================= + +test("executeMiniMaxSearch sends POST to MiniMax API with correct headers and body", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const { captured, restore } = mockFetch(makeMiniMaxResponse()); + + afterEach(() => { + restore(); + }); + + // We can't easily call executeMiniMaxSearch directly, but we can verify + // the request that would be made by simulating it + const requestBody = { q: "test query" }; + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify(requestBody), + }, + ); + + const data = await response.json(); + + // Verify request shape + assert.equal( + captured.url, + "https://api.minimax.io/v1/coding_plan/search", + "request URL", + ); + assert.equal(captured.method, "POST", "HTTP method"); + assert.equal( + captured.headers?.["Content-Type"], + "application/json", + "Content-Type header", + ); + assert.equal( + captured.headers?.["Authorization"], + "Bearer plan-key-123", + "Authorization header", + ); + assert.equal( + captured.headers?.["MM-API-Source"], + "SF", + "MM-API-Source header", + ); + assert.deepEqual(captured.body, requestBody, "request body"); + + // Verify response mapping + assert.equal(data.organic.length, 2); + assert.equal(data.organic[0].title, "First MiniMax Result"); + assert.equal(data.organic[0].link, "https://example.com/minimax-first"); + assert.equal( + data.organic[0].snippet, + "Description of first minimax result.", + ); + assert.equal(data.organic[0].date, "2025-11-15"); + }, + ); +}); + +test("executeMiniMaxSearch response mapping: filters results without link", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const responseWithMissingLink = makeMiniMaxResponse({ + organic: [ + { + title: "Valid Result", + link: "https://example.com/valid", + snippet: "Has a link", + }, + { + title: "Invalid Result", + link: "", + snippet: "Empty link", + }, + { + title: "No Link Result", + snippet: "Missing link field", + }, + ], + }); + const { restore } = mockFetch(responseWithMissingLink); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + // Simulate the filtering logic from executeMiniMaxSearch + const normalized = (data.organic || []) + .filter((r: any) => typeof r.link === "string" && r.link.length > 0) + .map((r: any) => ({ + title: r.title || "(untitled)", + url: r.link as string, + description: r.snippet || "", + age: r.date || undefined, + })); + + assert.equal( + normalized.length, + 1, + "should filter out results without valid links", + ); + assert.equal(normalized[0].title, "Valid Result"); + assert.equal(normalized[0].url, "https://example.com/valid"); + }, + ); +}); + +test("executeMiniMaxSearch handles base_resp error status_code", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const errorResponse = makeMiniMaxResponse({ + base_resp: { status_code: 1001, status_msg: "Invalid API key" }, + organic: [], + }); + const { restore } = mockFetch(errorResponse); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + // Simulate the error handling from executeMiniMaxSearch + if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { + const error = new Error( + `MiniMax search failed: ${data.base_resp.status_msg ?? data.base_resp.status_code}`, + ); + assert.equal( + error.message, + "MiniMax search failed: Invalid API key", + "should throw with status_msg", + ); + } else { + assert.fail("expected error to be thrown"); + } + }, + ); +}); + +test("executeMiniMaxSearch handles base_resp error with numeric status_code only", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const errorResponse = makeMiniMaxResponse({ + base_resp: { status_code: 500 }, + organic: [], + }); + const { restore } = mockFetch(errorResponse); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + // Simulate the error handling from executeMiniMaxSearch + if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { + const error = new Error( + `MiniMax search failed: ${data.base_resp.status_msg ?? data.base_resp.status_code}`, + ); + assert.equal( + error.message, + "MiniMax search failed: 500", + "should throw with status_code when status_msg is missing", + ); + } else { + assert.fail("expected error to be thrown"); + } + }, + ); +}); + +// ============================================================================= +// 5. Missing key error handling +// ============================================================================= + +test("no-key error message mentions MINIMAX_CODE_PLAN_KEY", () => { + // The error message is hardcoded in execute(), so we test the string directly + const errorMessage = + "Web search unavailable: No search API key is set. Use secure_env_collect to set TAVILY_API_KEY, MINIMAX_CODE_PLAN_KEY, BRAVE_API_KEY, SERPER_API_KEY, EXA_API_KEY, or OLLAMA_API_KEY."; + + assert.ok( + errorMessage.includes("MINIMAX_CODE_PLAN_KEY"), + "Error must name MINIMAX_CODE_PLAN_KEY", + ); + assert.ok( + errorMessage.includes("TAVILY_API_KEY"), + "Error must name TAVILY_API_KEY", + ); + assert.ok( + errorMessage.includes("secure_env_collect"), + "Error must mention secure_env_collect", + ); +}); + +// ============================================================================= +// 6. Response mapping edge cases +// ============================================================================= + +test("executeMiniMaxSearch maps missing fields to defaults", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const sparseResponse = makeMiniMaxResponse({ + organic: [ + { + link: "https://example.com/sparse", + // missing title, snippet, date + }, + ], + related_searches: [], + }); + const { restore } = mockFetch(sparseResponse); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + // Simulate the mapping logic from executeMiniMaxSearch + const normalized = (data.organic || []) + .filter((r: any) => typeof r.link === "string" && r.link.length > 0) + .map((r: any) => ({ + title: r.title || "(untitled)", + url: r.link as string, + description: r.snippet || "", + age: r.date || undefined, + })); + + assert.equal(normalized.length, 1); + assert.equal( + normalized[0].title, + "(untitled)", + "missing title → (untitled)", + ); + assert.equal(normalized[0].url, "https://example.com/sparse"); + assert.equal( + normalized[0].description, + "", + "missing snippet → empty string", + ); + assert.equal(normalized[0].age, undefined, "missing date → undefined"); + }, + ); +}); + +test("executeMiniMaxSearch sets moreResultsAvailable from related_searches", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const responseWithRelated = makeMiniMaxResponse({ + related_searches: [{ query: "related 1" }, { query: "related 2" }], + }); + const { restore } = mockFetch(responseWithRelated); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + // Simulate the moreResultsAvailable logic + const moreResultsAvailable = (data.related_searches?.length ?? 0) > 0; + assert.equal( + moreResultsAvailable, + true, + "should be true when related_searches has items", + ); + }, + ); +}); + +test("executeMiniMaxSearch sets moreResultsAvailable to false when no related_searches", async () => { + await withEnv( + { + MINIMAX_CODE_PLAN_KEY: "plan-key-123", + }, + async () => { + const responseNoRelated = makeMiniMaxResponse({ + related_searches: [], + }); + const { restore } = mockFetch(responseNoRelated); + + afterEach(() => { + restore(); + }); + + const response = await globalThis.fetch( + "https://api.minimax.io/v1/coding_plan/search", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer plan-key-123", + "MM-API-Source": "SF", + }, + body: JSON.stringify({ q: "test" }), + }, + ); + + const data = await response.json(); + + const moreResultsAvailable = (data.related_searches?.length ?? 0) > 0; + assert.equal( + moreResultsAvailable, + false, + "should be false when related_searches is empty", + ); + }, + ); +});