diff --git a/AGENTS.md b/AGENTS.md index 1cc8dad..3d3371b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ * **OpenCode plugin SDK has no embedding API — vector search blocked**: The OpenCode plugin SDK (\`@opencode-ai/plugin\`, \`@opencode-ai/sdk\`) exposes only session/chat/tool operations. There is no \`client.embed()\`, embeddings endpoint, or raw model inference API. The only LLM access is \`client.session.prompt()\` which creates full chat roundtrips through the agentic loop. This means Lore cannot do vector/embedding search without either: (1) OpenCode adding an embedding API, or (2) direct \`fetch()\` to provider APIs bypassing the SDK (fragile — requires key extraction from \`client.config.providers()\`). The FTS5 + RRF search infrastructure is designed to be additive — vector search would layer on top as another RRF input list, not replace BM25. + +* **Worker session prompt helper with agent-not-found retry**: src/worker.ts owns workerSessionIDs Set, isWorkerSession(), and promptWorker(). promptWorker() calls session.prompt() and uses the return value directly (no redundant session.messages() call). On 'agent not found' errors (detected via regex on JSON.stringify(result.error)), it retries once without the agent parameter on a fresh session. All callers (distillation×2, curator×2, search×1) use this shared helper. Session rotation (deleting from the caller's Map) happens after every call. The retry creates a new child session via client.session.create() and registers its ID in workerSessionIDs. + ### Decision diff --git a/src/curator.ts b/src/curator.ts index 2994446..64255aa 100644 --- a/src/curator.ts +++ b/src/curator.ts @@ -2,8 +2,9 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"; import { config } from "./config"; import * as temporal from "./temporal"; import * as ltm from "./ltm"; +import * as log from "./log"; import { CURATOR_SYSTEM, curatorUser, CONSOLIDATION_SYSTEM, consolidationUser } from "./prompt"; -import { workerSessionIDs } from "./distillation"; +import { workerSessionIDs, promptWorker } from "./worker"; /** * Maximum length (chars) for a single knowledge entry's content. @@ -103,33 +104,18 @@ export async function run(input: { { type: "text" as const, text: `${CURATOR_SYSTEM}\n\n${userContent}` }, ]; - await input.client.session.prompt({ - path: { id: workerID }, - body: { - parts, - agent: "lore-curator", - ...(model ? { model } : {}), - }, + const responseText = await promptWorker({ + client: input.client, + workerID, + parts, + agent: "lore-curator", + model, + sessionMap: workerSessions, + sessionKey: input.sessionID, }); + if (!responseText) return { created: 0, updated: 0, deleted: 0 }; - const msgs = await input.client.session.messages({ - path: { id: workerID }, - query: { limit: 2 }, - }); - // Rotate worker session so the next call starts fresh — prevents - // accumulating multiple assistant messages with reasoning/thinking parts, - // which providers reject ("Multiple reasoning_opaque values"). - workerSessions.delete(input.sessionID); - - const last = msgs.data?.at(-1); - if (!last || last.info.role !== "assistant") - return { created: 0, updated: 0, deleted: 0 }; - - const responsePart = last.parts.find((p) => p.type === "text"); - if (!responsePart || responsePart.type !== "text") - return { created: 0, updated: 0, deleted: 0 }; - - const ops = parseOps(responsePart.text); + const ops = parseOps(responseText); let created = 0; let updated = 0; let deleted = 0; @@ -230,29 +216,18 @@ export async function consolidate(input: { { type: "text" as const, text: `${CONSOLIDATION_SYSTEM}\n\n${userContent}` }, ]; - await input.client.session.prompt({ - path: { id: workerID }, - body: { - parts, - agent: "lore-curator", - ...(model ? { model } : {}), - }, + const responseText = await promptWorker({ + client: input.client, + workerID, + parts, + agent: "lore-curator", + model, + sessionMap: workerSessions, + sessionKey: input.sessionID, }); + if (!responseText) return { updated: 0, deleted: 0 }; - const msgs = await input.client.session.messages({ - path: { id: workerID }, - query: { limit: 2 }, - }); - // Rotate worker session — see run() comment. - workerSessions.delete(input.sessionID); - - const last = msgs.data?.at(-1); - if (!last || last.info.role !== "assistant") return { updated: 0, deleted: 0 }; - - const responsePart = last.parts.find((p) => p.type === "text"); - if (!responsePart || responsePart.type !== "text") return { updated: 0, deleted: 0 }; - - const ops = parseOps(responsePart.text); + const ops = parseOps(responseText); let updated = 0; let deleted = 0; diff --git a/src/distillation.ts b/src/distillation.ts index 0411acc..8494098 100644 --- a/src/distillation.ts +++ b/src/distillation.ts @@ -11,6 +11,10 @@ import { recursiveUser, } from "./prompt"; import { needsUrgentDistillation } from "./gradient"; +import { workerSessionIDs, promptWorker } from "./worker"; + +// Re-export for backwards compat — index.ts and others may still import from here. +export { workerSessionIDs }; type Client = ReturnType; type TemporalMessage = temporal.TemporalMessage; @@ -18,14 +22,6 @@ type TemporalMessage = temporal.TemporalMessage; // Worker sessions keyed by parent session ID — hidden children, one per source session const workerSessions = new Map(); -// Set of worker session IDs — used to skip storage and distillation for worker sessions -// Exported so curator.ts can register its own worker sessions here too -export const workerSessionIDs = new Set(); - -export function isWorkerSession(sessionID: string): boolean { - return workerSessionIDs.has(sessionID); -} - async function ensureWorkerSession( client: Client, parentID: string, @@ -108,6 +104,17 @@ function latestObservations( return row?.observations || undefined; } +/** Safely parse the source_ids JSON column. Defaults to [] on corrupt data. */ +export function parseSourceIds(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + log.warn("corrupt source_ids in distillation, defaulting to []"); + return []; + } +} + export type Distillation = { id: string; project_id: string; @@ -141,7 +148,7 @@ export function loadForSession( }>; return rows.map((r) => ({ ...r, - source_ids: JSON.parse(r.source_ids) as string[], + source_ids: parseSourceIds(r.source_ids), })); } @@ -208,7 +215,7 @@ function loadGen0(projectPath: string, sessionID: string): Distillation[] { }>; return rows.map((r) => ({ ...r, - source_ids: JSON.parse(r.source_ids) as string[], + source_ids: parseSourceIds(r.source_ids), })); } @@ -242,7 +249,7 @@ function resetOrphans(projectPath: string, sessionID: string): number { .all(pid, sessionID) as Array<{ source_ids: string }>; const covered = new Set(); for (const r of rows) { - for (const id of JSON.parse(r.source_ids) as string[]) covered.add(id); + for (const id of parseSourceIds(r.source_ids)) covered.add(id); } if (rows.length === 0) { // No distillations at all — reset everything to undistilled @@ -375,32 +382,18 @@ async function distillSegment(input: { { type: "text" as const, text: `${DISTILLATION_SYSTEM}\n\n${userContent}` }, ]; - await input.client.session.prompt({ - path: { id: workerID }, - body: { - parts, - agent: "lore-distill", - ...(model ? { model } : {}), - }, + const responseText = await promptWorker({ + client: input.client, + workerID, + parts, + agent: "lore-distill", + model, + sessionMap: workerSessions, + sessionKey: input.sessionID, }); + if (!responseText) return null; - // Read the response - const msgs = await input.client.session.messages({ - path: { id: workerID }, - query: { limit: 2 }, - }); - // Rotate worker session so the next call starts fresh — prevents - // accumulating multiple assistant messages with reasoning/thinking parts, - // which providers reject ("Multiple reasoning_opaque values"). - workerSessions.delete(input.sessionID); - - const last = msgs.data?.at(-1); - if (!last || last.info.role !== "assistant") return null; - - const responsePart = last.parts.find((p) => p.type === "text"); - if (!responsePart || responsePart.type !== "text") return null; - - const result = parseDistillationResult(responsePart.text); + const result = parseDistillationResult(responseText); if (!result) return null; const distillId = storeDistillation({ @@ -437,29 +430,18 @@ async function metaDistill(input: { { type: "text" as const, text: `${RECURSIVE_SYSTEM}\n\n${userContent}` }, ]; - await input.client.session.prompt({ - path: { id: workerID }, - body: { - parts, - agent: "lore-distill", - ...(model ? { model } : {}), - }, + const responseText = await promptWorker({ + client: input.client, + workerID, + parts, + agent: "lore-distill", + model, + sessionMap: workerSessions, + sessionKey: input.sessionID, }); + if (!responseText) return null; - const msgs = await input.client.session.messages({ - path: { id: workerID }, - query: { limit: 2 }, - }); - // Rotate worker session — see distillSegment() comment. - workerSessions.delete(input.sessionID); - - const last = msgs.data?.at(-1); - if (!last || last.info.role !== "assistant") return null; - - const responsePart = last.parts.find((p) => p.type === "text"); - if (!responsePart || responsePart.type !== "text") return null; - - const result = parseDistillationResult(responsePart.text); + const result = parseDistillationResult(responseText); if (!result) return null; // Store the meta-distillation at generation N+1 diff --git a/src/index.ts b/src/index.ts index 163f17d..1ad484f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { shouldImport, importFromFile, exportToFile } from "./agents-file"; import * as latReader from "./lat-reader"; import * as embedding from "./embedding"; import * as log from "./log"; +import { isWorkerSession } from "./worker"; /** * Detect whether an error from session.error is a context overflow ("prompt too long"). @@ -167,7 +168,7 @@ export const LorePlugin: Plugin = async (ctx) => { const skipSessions = new Set(); async function shouldSkip(sessionID: string): Promise { - if (distillation.isWorkerSession(sessionID)) return true; + if (isWorkerSession(sessionID)) return true; if (skipSessions.has(sessionID)) return true; if (activeSessions.has(sessionID)) return false; // already known good // First encounter — check if this is a child session. diff --git a/src/search.ts b/src/search.ts index 7060f5c..79f8035 100644 --- a/src/search.ts +++ b/src/search.ts @@ -272,7 +272,7 @@ export function reciprocalRankFusion( // --------------------------------------------------------------------------- import type { createOpencodeClient } from "@opencode-ai/sdk"; -import { workerSessionIDs } from "./distillation"; +import { workerSessionIDs, promptWorker } from "./worker"; import { QUERY_EXPANSION_SYSTEM } from "./prompt"; import * as log from "./log"; @@ -326,39 +326,26 @@ export async function expandQuery( ]; // Race the LLM call against a timeout - const result = await Promise.race([ - client.session.prompt({ - path: { id: workerID }, - body: { - parts, - agent: "lore-query-expand", - ...(model ? { model } : {}), - }, + const responseText = await Promise.race([ + promptWorker({ + client, + workerID, + parts, + agent: "lore-query-expand", + model, + sessionMap: expansionWorkerSessions, + sessionKey: sessionID, }), new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)), ]); - // Rotate worker session so the next call starts fresh - expansionWorkerSessions.delete(sessionID); - - if (!result) { - log.info("query expansion timed out, using original query"); + if (!responseText) { + log.info("query expansion timed out or failed, using original query"); return [query]; } - // Read the response - const msgs = await client.session.messages({ - path: { id: workerID }, - query: { limit: 2 }, - }); - const last = msgs.data?.at(-1); - if (!last || last.info.role !== "assistant") return [query]; - - const responsePart = last.parts.find((p) => p.type === "text"); - if (!responsePart || responsePart.type !== "text") return [query]; - // Parse JSON array from response - const cleaned = responsePart.text + const cleaned = responseText .trim() .replace(/^```json?\s*/i, "") .replace(/\s*```$/i, ""); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..e2b0c33 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,158 @@ +/** + * Shared worker session management and resilient LLM prompting. + * + * All lore background tasks (distillation, curation, query expansion) use + * hidden child sessions to call the LLM. This module owns the shared + * workerSessionIDs set and provides promptWorker() — a helper that: + * 1. Calls session.prompt() and uses the response directly (no redundant + * session.messages() round-trip). + * 2. Detects "agent not found" errors (when OpenCode loses plugin agent + * registrations after a config re-read) and retries without the agent + * parameter. + * 3. Rotates the worker session after each call to prevent accumulating + * multiple assistant messages with reasoning/thinking parts. + */ +import type { createOpencodeClient } from "@opencode-ai/sdk"; +import * as log from "./log"; + +type Client = ReturnType; + +// --------------------------------------------------------------------------- +// Shared worker session tracking +// --------------------------------------------------------------------------- + +/** Set of ALL worker session IDs across distillation, curator, and query expansion. + * Used by shouldSkip() in index.ts to avoid storing/distilling worker messages. */ +export const workerSessionIDs = new Set(); + +export function isWorkerSession(sessionID: string): boolean { + return workerSessionIDs.has(sessionID); +} + +// --------------------------------------------------------------------------- +// Resilient worker prompting +// --------------------------------------------------------------------------- + +/** + * Send a prompt to a worker session and return the assistant's text response. + * + * Uses the session.prompt() return value directly instead of making a separate + * session.messages() call. If the prompt fails because the agent is not found + * (OpenCode lost plugin agent registrations), retries once without the agent + * parameter. + * + * @returns The assistant's text response, or `null` if the prompt failed. + */ +export async function promptWorker(opts: { + client: Client; + workerID: string; + parts: Array<{ type: "text"; text: string }>; + agent: string; + model?: { providerID: string; modelID: string }; + /** Module-local worker session map — entry is deleted after the call (rotation). */ + sessionMap: Map; + /** Key in sessionMap (typically the parent session ID). Also used as parentID + * when creating a fresh session for retry. */ + sessionKey: string; +}): Promise { + const { client, parts, agent, model, sessionMap, sessionKey } = opts; + let { workerID } = opts; + + // First attempt — with agent + let result: { data?: unknown; error?: unknown }; + try { + result = await client.session.prompt({ + path: { id: workerID }, + body: { + parts, + agent, + ...(model ? { model } : {}), + }, + }); + } catch (e) { + // SDK may throw instead of returning an error object (e.g. malformed + // response body → JSON parse error). Treat as a prompt failure. + result = { error: e }; + } + + // Always rotate the worker session after a prompt attempt — prevents + // accumulating multiple assistant messages with reasoning/thinking parts, + // which providers reject ("Multiple reasoning_opaque values"). + sessionMap.delete(sessionKey); + + const text = extractText(result); + if (text !== null) return text; + + // Check for agent-not-found → retry without agent + const errStr = stringifyError(result.error); + if (/agent[^"]*not found/i.test(errStr)) { + log.warn(`agent "${agent}" not found, retrying without agent`); + + // Create a fresh worker session for the retry + let retryWorkerID: string; + try { + const session = await client.session.create({ + body: { parentID: sessionKey }, + }); + if (!session.data) { + log.warn("failed to create retry worker session"); + return null; + } + retryWorkerID = session.data.id; + workerSessionIDs.add(retryWorkerID); + } catch (e) { + log.warn("failed to create retry worker session:", e); + return null; + } + + let retry: { data?: unknown; error?: unknown }; + try { + retry = await client.session.prompt({ + path: { id: retryWorkerID }, + body: { + parts, + // No agent parameter — use session defaults + ...(model ? { model } : {}), + }, + }); + } catch (e) { + retry = { error: e }; + } + + const retryText = extractText(retry); + if (retryText !== null) return retryText; + + log.warn("worker prompt retry also failed:", retry.error); + return null; + } + + log.warn("worker prompt failed:", result.error); + return null; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract the first text part from a session.prompt() result. */ +function extractText(result: { data?: unknown; error?: unknown }): string | null { + if (!result.data || typeof result.data !== "object") return null; + const data = result.data as { parts?: Array<{ type: string; text?: string }> }; + if (!data.parts || !Array.isArray(data.parts)) return null; + const textPart = data.parts.find( + (p): p is { type: "text"; text: string } => + p.type === "text" && typeof p.text === "string", + ); + return textPart?.text ?? null; +} + +/** Safely stringify an error for regex matching. */ +function stringifyError(error: unknown): string { + if (!error) return ""; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/test/worker.test.ts b/test/worker.test.ts new file mode 100644 index 0000000..7f17c8f --- /dev/null +++ b/test/worker.test.ts @@ -0,0 +1,308 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { promptWorker, workerSessionIDs } from "../src/worker"; +import { parseSourceIds } from "../src/distillation"; + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +function mockClient(overrides: { + prompt?: (opts: unknown) => Promise; + create?: (opts: unknown) => Promise; +} = {}) { + return { + session: { + prompt: overrides.prompt ?? mock(() => Promise.resolve({ data: undefined })), + create: overrides.create ?? mock(() => + Promise.resolve({ data: { id: "retry-session-id" } }), + ), + }, + } as unknown as Parameters[0]["client"]; +} + +function successResult(text: string) { + return { + data: { + info: { role: "assistant" }, + parts: [{ type: "text", text, id: "p1", sessionID: "s1", messageID: "m1" }], + }, + }; +} + +function errorResult(message: string) { + return { error: { name: "NotFoundError", data: { message } } }; +} + +// --------------------------------------------------------------------------- +// promptWorker tests +// --------------------------------------------------------------------------- + +describe("promptWorker", () => { + let sessionMap: Map; + + beforeEach(() => { + sessionMap = new Map([["parent-1", "worker-1"]]); + workerSessionIDs.clear(); + }); + + test("success — returns text from assistant response", async () => { + const client = mockClient({ + prompt: mock(() => Promise.resolve(successResult("hello world"))), + }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test prompt" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBe("hello world"); + }); + + test("session rotation — sessionMap entry deleted after success", async () => { + const client = mockClient({ + prompt: mock(() => Promise.resolve(successResult("ok"))), + }); + + expect(sessionMap.has("parent-1")).toBe(true); + await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + expect(sessionMap.has("parent-1")).toBe(false); + }); + + test("session rotation — sessionMap entry deleted after failure", async () => { + const client = mockClient({ + prompt: mock(() => Promise.resolve(errorResult("rate limited"))), + }); + + expect(sessionMap.has("parent-1")).toBe(true); + await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + expect(sessionMap.has("parent-1")).toBe(false); + }); + + test("non-agent error — returns null, no retry", async () => { + const promptFn = mock(() => + Promise.resolve(errorResult("rate limit exceeded")), + ); + const createFn = mock(() => + Promise.resolve({ data: { id: "should-not-be-called" } }), + ); + const client = mockClient({ prompt: promptFn, create: createFn }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBeNull(); + // Should NOT have created a retry session + expect(createFn).not.toHaveBeenCalled(); + // prompt called exactly once (no retry) + expect(promptFn).toHaveBeenCalledTimes(1); + }); + + test("agent-not-found → retry succeeds", async () => { + let callCount = 0; + const promptFn = mock(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve( + errorResult('Agent not found: "lore-distill". Available agents: build, explore, general, plan'), + ); + } + return Promise.resolve(successResult("retried response")); + }); + const createFn = mock(() => + Promise.resolve({ data: { id: "retry-session-id" } }), + ); + const client = mockClient({ prompt: promptFn, create: createFn }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBe("retried response"); + expect(promptFn).toHaveBeenCalledTimes(2); + expect(createFn).toHaveBeenCalledTimes(1); + // Retry session registered in workerSessionIDs + expect(workerSessionIDs.has("retry-session-id")).toBe(true); + }); + + test("agent-not-found → retry also fails", async () => { + const promptFn = mock(() => + Promise.resolve( + errorResult('Agent not found: "lore-distill". Available agents: build, explore, general, plan'), + ), + ); + const client = mockClient({ prompt: promptFn }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBeNull(); + expect(promptFn).toHaveBeenCalledTimes(2); + }); + + test("no text part in response — returns null", async () => { + const client = mockClient({ + prompt: mock(() => + Promise.resolve({ + data: { + info: { role: "assistant" }, + parts: [{ type: "reasoning", text: "thinking..." }], + }, + }), + ), + }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBeNull(); + }); + + test("SDK throws (e.g. JSON parse error) — returns null, retries on agent-not-found", async () => { + const client = mockClient({ + prompt: mock(() => + Promise.reject(new SyntaxError("JSON Parse error: Unexpected EOF")), + ), + }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + // SyntaxError doesn't match "agent not found" — no retry, returns null + expect(result).toBeNull(); + }); + + test("agent-not-found with SDK throw on retry succeeds", async () => { + let callCount = 0; + const promptFn = mock(() => { + callCount++; + if (callCount === 1) { + // Simulate SDK throwing with agent-not-found in the error message + return Promise.reject( + Object.assign(new Error("Agent not found: lore-distill"), { + data: { message: 'Agent not found: "lore-distill"' }, + }), + ); + } + return Promise.resolve(successResult("recovered")); + }); + const client = mockClient({ prompt: promptFn }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBe("recovered"); + expect(promptFn).toHaveBeenCalledTimes(2); + }); + + test("retry skipped when session creation fails", async () => { + const promptFn = mock(() => + Promise.resolve( + errorResult('Agent not found: "lore-distill"'), + ), + ); + const createFn = mock(() => + Promise.resolve({ data: undefined }), + ); + const client = mockClient({ prompt: promptFn, create: createFn }); + + const result = await promptWorker({ + client, + workerID: "worker-1", + parts: [{ type: "text", text: "test" }], + agent: "lore-distill", + sessionMap, + sessionKey: "parent-1", + }); + + expect(result).toBeNull(); + // prompt only called once — retry skipped because session creation returned no data + expect(promptFn).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// parseSourceIds tests +// --------------------------------------------------------------------------- + +describe("parseSourceIds", () => { + test("valid JSON array", () => { + expect(parseSourceIds('["a","b","c"]')).toEqual(["a", "b", "c"]); + }); + + test("empty array", () => { + expect(parseSourceIds("[]")).toEqual([]); + }); + + test("empty string — returns []", () => { + expect(parseSourceIds("")).toEqual([]); + }); + + test("malformed JSON — returns []", () => { + expect(parseSourceIds("{not valid")).toEqual([]); + }); + + test("non-array JSON (object) — returns []", () => { + expect(parseSourceIds('{"key": "value"}')).toEqual([]); + }); + + test("non-array JSON (string) — returns []", () => { + expect(parseSourceIds('"just a string"')).toEqual([]); + }); + + test("non-array JSON (number) — returns []", () => { + expect(parseSourceIds("42")).toEqual([]); + }); +});