From b23be7728c2924e5626f03a2c2e74f406b4c627b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 04:23:29 +0000 Subject: [PATCH] test(api): add agents wikiCompose/research node tests (#1033) Add unit tests for draftSections, briefDialogue, humanReviewBrief, fetchArticles, resolveWebSearchModel, webSearch, and usageCallback. Each target file reaches 80%+ line coverage. Align draftSections error fallback with #976: generic user-safe message, append note on partial stream failure. Co-authored-by: Akimasa Sugai --- .../agents/core/llm/usageCallback.test.ts | 63 +++++- .../core/tools/resolveWebSearchModel.test.ts | 62 ++++++ .../wikiCompose/nodes/briefDialogue.test.ts | 180 +++++++++++++++ .../wikiCompose/nodes/draftSections.test.ts | 205 ++++++++++++++++++ .../nodes/humanReviewBrief.test.ts | 126 +++++++++++ .../research/nodes/fetchArticles.test.ts | 140 ++++++++++++ .../research/tools/webSearch.test.ts | 112 +++++++--- .../graphs/wikiCompose/nodes/draftSections.ts | 13 +- 8 files changed, 865 insertions(+), 36 deletions(-) create mode 100644 server/api/src/__tests__/agents/core/tools/resolveWebSearchModel.test.ts create mode 100644 server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts create mode 100644 server/api/src/__tests__/agents/graphs/wikiCompose/nodes/draftSections.test.ts create mode 100644 server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts create mode 100644 server/api/src/__tests__/agents/subgraphs/research/nodes/fetchArticles.test.ts diff --git a/server/api/src/__tests__/agents/core/llm/usageCallback.test.ts b/server/api/src/__tests__/agents/core/llm/usageCallback.test.ts index d1764078..bc346226 100644 --- a/server/api/src/__tests__/agents/core/llm/usageCallback.test.ts +++ b/server/api/src/__tests__/agents/core/llm/usageCallback.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { recordZediUsage } from "../../../../agents/core/llm/usageCallback.js"; +import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { recordZediUsage, toZediMessages } from "../../../../agents/core/llm/usageCallback.js"; const mockRecordUsage = vi.fn(); const mockCalculateCost = vi.fn(); @@ -27,7 +28,7 @@ describe("recordZediUsage", () => { outputCostUnits: 20, apiMode: "user_key", }); - expect(result.costUnits).toBe(0); + expect(result).toEqual({ inputTokens: 100, outputTokens: 50, costUnits: 0 }); expect(mockRecordUsage).toHaveBeenCalledWith( "u1", "openai:gpt-4o-mini", @@ -51,7 +52,8 @@ describe("recordZediUsage", () => { outputCostUnits: 20, apiMode: "system", }); - expect(result.costUnits).toBe(42); + expect(result).toEqual({ inputTokens: 100, outputTokens: 50, costUnits: 42 }); + expect(mockCalculateCost).toHaveBeenCalledWith({ inputTokens: 100, outputTokens: 50 }, 10, 20); expect(mockRecordUsage).toHaveBeenCalledWith( "u1", "openai:gpt-4o-mini", @@ -62,4 +64,59 @@ describe("recordZediUsage", () => { db, ); }); + + it("passes zero-token usage through to recordUsage unchanged", async () => { + mockCalculateCost.mockReturnValue(0); + const db = {} as never; + const result = await recordZediUsage({ + db, + userId: "u1", + modelId: "openai:gpt-4o-mini", + feature: "wiki_compose:test", + usage: { inputTokens: 0, outputTokens: 0 }, + inputCostUnits: 10, + outputCostUnits: 20, + apiMode: "system", + }); + expect(result).toEqual({ inputTokens: 0, outputTokens: 0, costUnits: 0 }); + expect(mockRecordUsage).toHaveBeenCalledWith( + "u1", + "openai:gpt-4o-mini", + "wiki_compose:test", + { inputTokens: 0, outputTokens: 0 }, + 0, + "system", + db, + ); + }); +}); + +describe("toZediMessages", () => { + it("maps system, assistant, and user roles from LangChain message types", () => { + const converted = toZediMessages([ + new SystemMessage("sys"), + new AIMessage("assistant"), + new HumanMessage("human"), + ]); + expect(converted).toEqual([ + { role: "system", content: "sys" }, + { role: "assistant", content: "assistant" }, + { role: "user", content: "human" }, + ]); + }); + + it("concatenates text blocks from multi-part content arrays", () => { + const converted = toZediMessages([ + new HumanMessage([ + { type: "text", text: "hello " }, + { type: "text", text: "world" }, + ]), + ]); + expect(converted).toEqual([{ role: "user", content: "hello world" }]); + }); + + it("returns empty string for unsupported content shapes", () => { + const converted = toZediMessages([new HumanMessage([{ type: "image_url", url: "x" }])]); + expect(converted).toEqual([{ role: "user", content: "" }]); + }); }); diff --git a/server/api/src/__tests__/agents/core/tools/resolveWebSearchModel.test.ts b/server/api/src/__tests__/agents/core/tools/resolveWebSearchModel.test.ts new file mode 100644 index 00000000..f9389c32 --- /dev/null +++ b/server/api/src/__tests__/agents/core/tools/resolveWebSearchModel.test.ts @@ -0,0 +1,62 @@ +/** + * `resolveWebSearchModelId` unit tests (#1033). + * Priority: fixed Wiki Compose model → env override → cheapest OpenAI/Google. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WIKI_COMPOSE_MODEL_ID } from "../../../../agents/core/llm/wikiComposeModelId.js"; +import { resolveWebSearchModelId } from "../../../../agents/core/tools/resolveWebSearchModel.js"; +import { createMockDb } from "../../../createMockDb.js"; + +const ENV_KEY = "WIKI_COMPOSE_WEB_SEARCH_MODEL_ID"; + +beforeEach(() => { + vi.unstubAllEnvs(); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("resolveWebSearchModelId", () => { + it("returns the fixed Wiki Compose model when active and tier-accessible", async () => { + const { db } = createMockDb([[{ id: WIKI_COMPOSE_MODEL_ID }]]); + const id = await resolveWebSearchModelId(db as never, "free"); + expect(id).toBe(WIKI_COMPOSE_MODEL_ID); + }); + + it("validates env override against active + tier before returning it", async () => { + vi.stubEnv(ENV_KEY, "openai:gpt-4o-mini"); + const { db } = createMockDb([[], [{ id: "openai:gpt-4o-mini" }]]); + const id = await resolveWebSearchModelId(db as never, "free"); + expect(id).toBe("openai:gpt-4o-mini"); + }); + + it("falls through when env override is inactive and picks cheapest OpenAI among ties", async () => { + vi.stubEnv(ENV_KEY, "openai:inactive-model"); + const { db } = createMockDb([ + [], + [], + [ + { + id: "google:cheap", + provider: "google", + inputCostUnits: 1, + outputCostUnits: 1, + }, + { + id: "openai:cheap", + provider: "openai", + inputCostUnits: 1, + outputCostUnits: 1, + }, + ], + ]); + const id = await resolveWebSearchModelId(db as never, "pro"); + expect(id).toBe("openai:cheap"); + }); + + it("returns null when no active OpenAI/Google models exist", async () => { + const { db } = createMockDb([[], []]); + const id = await resolveWebSearchModelId(db as never, "free"); + expect(id).toBeNull(); + }); +}); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts new file mode 100644 index 00000000..75cbf9d8 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts @@ -0,0 +1,180 @@ +/** + * `briefDialogue` unit tests (#1033). + * - Loads page snapshot once and projects briefQuestions into state. + * - LLM failure degrades to empty questions with `briefDegraded=true`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createZediChatModel, loadPageSnapshot } = vi.hoisted(() => ({ + createZediChatModel: vi.fn(), + loadPageSnapshot: vi.fn(), +})); + +vi.mock("../../../../../agents/core/llm/wikiComposeModelId.js", () => ({ + resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"), +})); + +vi.mock("../../../../../agents/core/llm/modelFactory.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../../agents/core/llm/modelFactory.js") + >("../../../../../agents/core/llm/modelFactory.js"); + return { + ...actual, + createZediChatModel: (...args: unknown[]) => + createZediChatModel(...(args as Parameters)), + }; +}); + +vi.mock("../../../../../agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.js", () => ({ + loadPageSnapshot: (...args: unknown[]) => + loadPageSnapshot( + ...(args as Parameters< + typeof import("../../../../../agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.js").loadPageSnapshot + >), + ), +})); + +vi.mock("../../../../../agents/graphs/wikiCompose/nodes/shared/dispatch.js", () => ({ + dispatchComposePhase: vi.fn(async () => undefined), +})); + +import { briefDialogue } from "../../../../../agents/graphs/wikiCompose/nodes/briefDialogue.js"; +import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js"; +import type { GraphContext } from "../../../../../agents/core/types/graphContext.js"; +import type { Database } from "../../../../../types/index.js"; +import type { WikiComposeStateType } from "../../../../../agents/graphs/wikiCompose/state.js"; +import type { + BriefQuestion, + PageSnapshot, +} from "../../../../../agents/graphs/wikiCompose/types.js"; + +function fakeContext(): GraphContext { + return { + threadId: "t", + sessionId: "t", + userId: "u-1", + pageId: "p-1", + graphId: "wiki-compose", + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "wiki_compose:test", + userEmail: null, + contentLocale: "ja", + }; +} + +function state(overrides: Partial): WikiComposeStateType { + return { + messages: [], + phase: "brief", + pageId: "p-1", + userId: "u-1", + iteration: 0, + maxIterations: 3, + queries: [], + pendingSources: [], + lastEvaluation: null, + exitReason: null, + batches: [], + approvedResearch: [], + rejectedResearch: [], + additionalRequest: null, + researchConflicts: [], + briefQuestions: [], + briefDegraded: false, + brief: null, + outlineProposal: [], + approvedOutline: null, + draftedSections: [], + completion: null, + chatSeed: null, + pageSnapshot: null, + ...overrides, + }; +} + +function fakeStructuredModel(returnValue: unknown) { + return { + withStructuredOutput: vi.fn(() => ({ + invoke: vi.fn(async () => returnValue), + })), + }; +} + +beforeEach(() => { + createZediChatModel.mockReset(); + loadPageSnapshot.mockReset(); + loadPageSnapshot.mockResolvedValue({ + pageId: "p-1", + title: "Loaded Title", + body: "Existing body", + hasContent: true, + }); +}); +afterEach(() => { + createZediChatModel.mockReset(); + loadPageSnapshot.mockReset(); +}); + +describe("briefDialogue", () => { + const config = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: fakeContext() } }; + + it("loads page snapshot when absent and projects structured brief questions", async () => { + createZediChatModel.mockResolvedValue( + fakeStructuredModel({ + questions: [ + { + question: "What audience?", + rationale: "Scope matters", + options: [{ label: "Developers", hint: "Technical readers" }], + required: true, + }, + ], + }), + ); + + const update = await briefDialogue(state({ pageSnapshot: null }), config as never); + + expect(loadPageSnapshot).toHaveBeenCalledTimes(1); + expect((update.pageSnapshot as PageSnapshot).title).toBe("Loaded Title"); + const questions = (update.briefQuestions ?? []) as BriefQuestion[]; + expect(questions).toHaveLength(1); + expect(questions[0]?.question).toBe("What audience?"); + expect(questions[0]?.options).toHaveLength(1); + expect(questions[0]?.required).toBe(true); + expect(update.briefDegraded).toBe(false); + expect(update.phase).toBe("brief:await_user"); + }); + + it("reuses pageSnapshot from state without hitting the DB", async () => { + createZediChatModel.mockResolvedValue(fakeStructuredModel({ questions: [] })); + const snapshot = { + pageId: "p-1", + title: "Cached", + body: "", + hasContent: false, + }; + + const update = await briefDialogue(state({ pageSnapshot: snapshot }), config as never); + + expect(loadPageSnapshot).not.toHaveBeenCalled(); + expect(update.pageSnapshot).toEqual(snapshot); + }); + + it("sets briefDegraded when the LLM call fails", async () => { + createZediChatModel.mockResolvedValue({ + withStructuredOutput: vi.fn(() => ({ + invoke: vi.fn(async () => { + throw new Error("provider timeout"); + }), + })), + }); + + const update = await briefDialogue(state({ pageSnapshot: null }), config as never); + + expect(update.briefQuestions).toEqual([]); + expect(update.briefDegraded).toBe(true); + expect(update.phase).toBe("brief:await_user"); + }); +}); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/draftSections.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/draftSections.test.ts new file mode 100644 index 00000000..744453ec --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/draftSections.test.ts @@ -0,0 +1,205 @@ +/** + * `draftSections` unit tests (#1033, #976). + * - Per-section LLM streaming with state projection. + * - One section failure must not abort the whole Draft. + * - User-visible body must not leak raw provider error messages. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() })); + +vi.mock("../../../../../agents/core/llm/wikiComposeModelId.js", () => ({ + resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"), +})); + +vi.mock("../../../../../agents/core/llm/modelFactory.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../../agents/core/llm/modelFactory.js") + >("../../../../../agents/core/llm/modelFactory.js"); + return { + ...actual, + createZediChatModel: (...args: unknown[]) => + createZediChatModel(...(args as Parameters)), + }; +}); + +vi.mock("../../../../../agents/graphs/wikiCompose/nodes/shared/dispatch.js", () => ({ + dispatchComposePhase: vi.fn(async () => undefined), + dispatchComposeSection: vi.fn(async () => undefined), +})); + +import { draftSections } from "../../../../../agents/graphs/wikiCompose/nodes/draftSections.js"; +import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js"; +import type { GraphContext } from "../../../../../agents/core/types/graphContext.js"; +import type { Database } from "../../../../../types/index.js"; +import type { WikiComposeStateType } from "../../../../../agents/graphs/wikiCompose/state.js"; +import type { DraftedSection } from "../../../../../agents/graphs/wikiCompose/types.js"; + +function fakeContext(): GraphContext { + return { + threadId: "t", + sessionId: "t", + userId: "u-1", + pageId: "p-1", + graphId: "wiki-compose", + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "wiki_compose:test", + userEmail: null, + contentLocale: "ja", + }; +} + +function state(overrides: Partial): WikiComposeStateType { + return { + messages: [], + phase: "draft", + pageId: "p-1", + userId: "u-1", + iteration: 0, + maxIterations: 3, + queries: [], + pendingSources: [], + lastEvaluation: null, + exitReason: null, + batches: [], + approvedResearch: [], + rejectedResearch: [], + additionalRequest: null, + researchConflicts: [], + briefQuestions: [], + briefDegraded: false, + brief: null, + outlineProposal: [], + approvedOutline: null, + draftedSections: [], + completion: null, + chatSeed: null, + pageSnapshot: { pageId: "p-1", title: "My Page", body: "", hasContent: false }, + ...overrides, + }; +} + +function streamFromChunks(chunks: unknown[]) { + return (async function* () { + for (const chunk of chunks) yield chunk; + })(); +} + +function fakeModelWithStreams(streams: unknown[][]) { + const stream = vi.fn(); + for (const chunks of streams) { + stream.mockResolvedValueOnce(streamFromChunks(chunks)); + } + return { stream }; +} + +beforeEach(() => { + createZediChatModel.mockReset(); +}); +afterEach(() => { + createZediChatModel.mockReset(); + vi.restoreAllMocks(); +}); + +describe("draftSections", () => { + const config = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: fakeContext() } }; + + it("returns empty draftedSections when approved outline has no sections", async () => { + const update = await draftSections( + state({ approvedOutline: { sections: [] } }), + config as never, + ); + expect(update.draftedSections).toEqual([]); + expect(update.phase).toBe("draft:completed"); + expect(createZediChatModel).not.toHaveBeenCalled(); + }); + + it("streams each section and collects cited source ids from [#N] markers", async () => { + createZediChatModel.mockResolvedValue( + fakeModelWithStreams([ + [{ content: "Intro body [#1]" }, { content: [{ type: "text", text: " more" }] }], + [{ content: "Details without citations" }], + ]), + ); + + const update = await draftSections( + state({ + approvedOutline: { + sections: [ + { id: "sec-1", heading: "Intro", depth: 1, intent: "overview", sourceIds: ["src:a"] }, + { id: "sec-2", heading: "Details", depth: 1, intent: "deep dive" }, + ], + }, + approvedResearch: [ + { id: "src:a", kind: "web", title: "A", url: "https://a/" }, + { id: "src:b", kind: "web", title: "B", url: "https://b/" }, + ], + brief: { answers: [], summary: "scope", appendToExisting: false }, + }), + config as never, + ); + + expect(update.phase).toBe("draft:completed"); + const sections = (update.draftedSections ?? []) as DraftedSection[]; + expect(sections).toHaveLength(2); + expect(sections[0]?.body).toBe("Intro body [#1] more"); + expect(sections[0]?.citedSourceIds).toEqual(["src:a"]); + expect(sections[1]?.body).toBe("Details without citations"); + expect(sections[1]?.citedSourceIds).toEqual([]); + }); + + it("continues drafting when one section stream fails and omits raw error details", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const stream = vi.fn(); + stream.mockRejectedValueOnce(new Error("provider authentication failed: credential-refused")); + stream.mockResolvedValueOnce(streamFromChunks([{ content: "Recovered section" }])); + createZediChatModel.mockResolvedValue({ stream }); + + const update = await draftSections( + state({ + approvedOutline: { + sections: [ + { id: "sec-1", heading: "Fail", depth: 1, intent: "x" }, + { id: "sec-2", heading: "Ok", depth: 1, intent: "y" }, + ], + }, + }), + config as never, + ); + + const sections = (update.draftedSections ?? []) as DraftedSection[]; + expect(sections).toHaveLength(2); + expect(sections[0]?.body).toBe("*(Section draft failed. Please retry drafting this section.)*"); + expect(sections[0]?.body).not.toMatch(/credential-refused/); + expect(sections[0]?.body).not.toMatch(/provider authentication failed/); + expect(sections[1]?.body).toBe("Recovered section"); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("preserves partial streamed content when the stream throws mid-section", async () => { + createZediChatModel.mockResolvedValue({ + stream: vi.fn(async () => ({ + async *[Symbol.asyncIterator]() { + yield { content: "Partial " }; + throw new Error("stream interrupted"); + }, + })), + }); + + const update = await draftSections( + state({ + approvedOutline: { + sections: [{ id: "sec-1", heading: "One", depth: 1, intent: "only" }], + }, + }), + config as never, + ); + + const sections = (update.draftedSections ?? []) as DraftedSection[]; + expect(sections[0]?.body).toBe( + "Partial\n\n*(Section draft failed. Please retry drafting this section.)*", + ); + }); +}); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts new file mode 100644 index 00000000..910024f5 --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/nodes/humanReviewBrief.test.ts @@ -0,0 +1,126 @@ +/** + * `humanReviewBrief` unit tests (#1033). + * - Interrupt payload shape and resume projection into `state.brief`. + * - Allowed and disallowed resume payloads (schema validation). + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { interrupt } = vi.hoisted(() => ({ interrupt: vi.fn() })); + +vi.mock("@langchain/langgraph", async () => { + const actual = + await vi.importActual("@langchain/langgraph"); + return { ...actual, interrupt }; +}); + +import { humanReviewBrief } from "../../../../../agents/graphs/wikiCompose/nodes/humanReviewBrief.js"; +import type { WikiComposeStateType } from "../../../../../agents/graphs/wikiCompose/state.js"; +import type { BriefResult } from "../../../../../agents/graphs/wikiCompose/types.js"; + +function state(overrides: Partial): WikiComposeStateType { + return { + messages: [], + phase: "brief:await_user", + pageId: "p-1", + userId: "u-1", + iteration: 0, + maxIterations: 3, + queries: [], + pendingSources: [], + lastEvaluation: null, + exitReason: null, + batches: [], + approvedResearch: [], + rejectedResearch: [], + additionalRequest: null, + researchConflicts: [], + briefQuestions: [ + { + id: "q-1", + question: "Target audience?", + options: [ + { id: "opt-a", label: "Developers" }, + { id: "opt-b", label: "General" }, + ], + required: false, + }, + ], + briefDegraded: false, + brief: null, + outlineProposal: [], + approvedOutline: null, + draftedSections: [], + completion: null, + chatSeed: null, + pageSnapshot: { pageId: "p-1", title: "Title", body: "body", hasContent: true }, + ...overrides, + }; +} + +beforeEach(() => { + interrupt.mockReset(); +}); + +describe("humanReviewBrief", () => { + it("interrupts with brief questions and projects resume answers into state.brief", async () => { + interrupt.mockReturnValueOnce({ + answers: [ + { + questionId: "q-1", + selectedOptionIds: ["opt-a"], + freeText: "Also for ops teams", + }, + ], + appendToExisting: true, + researchMaxIterations: 4, + }); + + const update = await humanReviewBrief(state({}), { configurable: {} } as never); + const brief = update.brief as BriefResult; + + expect(interrupt).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "human_review_brief", + questions: expect.arrayContaining([expect.objectContaining({ id: "q-1" })]), + }), + ); + expect(update.phase).toBe("brief:completed"); + expect(brief.appendToExisting).toBe(true); + expect(brief.answers).toEqual([ + { + questionId: "q-1", + selectedOptionIds: ["opt-a"], + freeText: "Also for ops teams", + }, + ]); + expect(brief.summary).toContain("Target audience?"); + expect(brief.summary).toContain("selected=opt-a"); + expect(update.maxIterations).toBe(4); + }); + + it("accepts an empty answers array (explicit Brief skip)", async () => { + interrupt.mockReturnValueOnce({ answers: [] }); + + const update = await humanReviewBrief(state({ briefQuestions: [] }), { + configurable: {}, + } as never); + const brief = update.brief as BriefResult; + + expect(brief.answers).toEqual([]); + expect(brief.summary).toBe("(no brief provided)"); + expect(brief.appendToExisting).toBe(false); + expect(update.maxIterations).toBeUndefined(); + }); + + it("rejects researchMaxIterations outside 1..5", async () => { + interrupt.mockReturnValueOnce({ answers: [], researchMaxIterations: 9 }); + + await expect(humanReviewBrief(state({}), { configurable: {} } as never)).rejects.toThrow(); + }); + + it("rejects answers missing questionId", async () => { + interrupt.mockReturnValueOnce({ answers: [{ selectedOptionIds: ["opt-a"] }] }); + + await expect(humanReviewBrief(state({}), { configurable: {} } as never)).rejects.toThrow(); + }); +}); diff --git a/server/api/src/__tests__/agents/subgraphs/research/nodes/fetchArticles.test.ts b/server/api/src/__tests__/agents/subgraphs/research/nodes/fetchArticles.test.ts new file mode 100644 index 00000000..0f1046bc --- /dev/null +++ b/server/api/src/__tests__/agents/subgraphs/research/nodes/fetchArticles.test.ts @@ -0,0 +1,140 @@ +/** + * `fetchArticles` unit tests (#1033). + * - Upgrades `web` rows to `kind:"fetched"` in place (same id). + * - Partial fetch failures leave other candidates untouched. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { isClipUrlAllowedAfterDns, extractArticleFromUrl } = vi.hoisted(() => ({ + isClipUrlAllowedAfterDns: vi.fn(), + extractArticleFromUrl: vi.fn(), +})); + +vi.mock("../../../../../lib/clipUrlPolicy.js", () => ({ + isClipUrlAllowedAfterDns: (...args: unknown[]) => + isClipUrlAllowedAfterDns( + ...(args as Parameters< + typeof import("../../../../../lib/clipUrlPolicy.js").isClipUrlAllowedAfterDns + >), + ), +})); + +vi.mock("../../../../../services/articleExtractor.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../../services/articleExtractor.js") + >("../../../../../services/articleExtractor.js"); + return { + ...actual, + extractArticleFromUrl: (...args: unknown[]) => + extractArticleFromUrl( + ...(args as Parameters< + typeof import("../../../../../services/articleExtractor.js").extractArticleFromUrl + >), + ), + }; +}); + +import { fetchArticles } from "../../../../../agents/subgraphs/research/nodes/fetchArticles.js"; +import type { ResearchLoopStateType } from "../../../../../agents/subgraphs/research/state.js"; +import type { Source } from "../../../../../agents/subgraphs/research/types.js"; + +function state(overrides: Partial): ResearchLoopStateType { + return { + messages: [], + phase: "research:fetch", + pageId: "p-1", + userId: "u-1", + iteration: 1, + maxIterations: 3, + queries: [], + pendingSources: [], + lastEvaluation: null, + exitReason: null, + batches: [], + approvedResearch: [], + rejectedResearch: [], + additionalRequest: null, + ...overrides, + }; +} + +beforeEach(() => { + isClipUrlAllowedAfterDns.mockReset(); + extractArticleFromUrl.mockReset(); + isClipUrlAllowedAfterDns.mockResolvedValue(true); +}); +afterEach(() => { + isClipUrlAllowedAfterDns.mockReset(); + extractArticleFromUrl.mockReset(); +}); + +describe("fetchArticles", () => { + it("returns empty pendingSources when there are no web candidates", async () => { + const update = await fetchArticles( + state({ + pendingSources: [{ id: "wiki:1", kind: "wiki", title: "W" }], + }), + { configurable: {} } as never, + ); + expect(update.pendingSources).toEqual([]); + }); + + it("upgrades successful fetches in place and skips failures", async () => { + extractArticleFromUrl + .mockResolvedValueOnce({ + finalUrl: "https://final/a", + title: "Article A", + thumbnailUrl: null, + tiptapJson: { type: "doc" }, + contentText: "excerpt A", + contentHash: "hash-a", + }) + .mockRejectedValueOnce(new Error("network fail")); + + const update = await fetchArticles( + state({ + pendingSources: [ + { id: "src:aaa", kind: "web", title: "A", url: "https://a/" }, + { id: "src:bbb", kind: "web", title: "B", url: "https://b/" }, + ], + }), + { configurable: {} } as never, + ); + + const upgraded = (update.pendingSources ?? []) as Source[]; + expect(upgraded).toHaveLength(1); + expect(upgraded[0]).toEqual( + expect.objectContaining({ + id: "src:aaa", + kind: "fetched", + title: "Article A", + url: "https://a/", + finalUrl: "https://final/a", + excerpt: "excerpt A", + contentHash: "hash-a", + }), + ); + }); + + it("fetches at most five web candidates per iteration", async () => { + extractArticleFromUrl.mockResolvedValue({ + finalUrl: "https://final/", + title: "T", + thumbnailUrl: null, + tiptapJson: { type: "doc" }, + contentText: "body", + contentHash: "h", + }); + + const pendingSources = Array.from({ length: 7 }, (_, i) => ({ + id: `src:${i}`, + kind: "web" as const, + title: `T${i}`, + url: `https://x/${i}`, + })); + + await fetchArticles(state({ pendingSources }), { configurable: {} } as never); + + expect(extractArticleFromUrl).toHaveBeenCalledTimes(5); + }); +}); diff --git a/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts b/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts index 98d06208..03ff38c2 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts @@ -1,33 +1,30 @@ /** - * `webSearchTool` unit tests. Covers: - * - Missing graph context → JSON envelope `{ ok:false, error:"missing_graph_context" }`. - * - No OpenAI/Google model configured → `{ ok:true, results:[], note:"web_search_unavailable" }` - * (the Anthropic-fallback path documented in the tool's JSDoc). - * - * We don't fully exercise the LLM path here — `researchGraph.modelGuard.test.ts` - * already verifies that the tool routes through `createZediChatModel`, and the - * structured-output shape is covered indirectly by the loop test. Adding a - * full network mock would be brittle for marginal value. + * `webSearchTool` unit tests (#1033). + * Mocks only LLM (`createZediChatModel`) and DB boundaries — not internal helpers. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WIKI_COMPOSE_MODEL_ID } from "../../../../../agents/core/llm/wikiComposeModelId.js"; +import { createMockDb } from "../../../../createMockDb.js"; -const { resolveWebSearchModelId } = vi.hoisted(() => ({ resolveWebSearchModelId: vi.fn() })); +const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() })); -vi.mock("../../../../../agents/core/tools/resolveWebSearchModel.js", () => ({ - resolveWebSearchModelId: (...args: unknown[]) => - resolveWebSearchModelId( - ...(args as Parameters< - typeof import("../../../../../agents/core/tools/resolveWebSearchModel.js").resolveWebSearchModelId - >), - ), -})); +vi.mock("../../../../../agents/core/llm/modelFactory.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../../agents/core/llm/modelFactory.js") + >("../../../../../agents/core/llm/modelFactory.js"); + return { + ...actual, + createZediChatModel: (...args: unknown[]) => + createZediChatModel(...(args as Parameters)), + }; +}); import { webSearchTool } from "../../../../../agents/core/tools/webSearch.js"; import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js"; import type { GraphContext } from "../../../../../agents/core/types/graphContext.js"; import type { Database } from "../../../../../types/index.js"; -function ctxConfig(): { configurable: Record } { +function ctxConfig(db: Database): { configurable: Record } { return { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: { @@ -38,7 +35,7 @@ function ctxConfig(): { configurable: Record } { graphId: "wiki-compose-research", backend: "zedi_managed", tier: "free", - db: {} as Database, + db, feature: "wiki_compose:research", userEmail: null, contentLocale: "ja", @@ -47,11 +44,19 @@ function ctxConfig(): { configurable: Record } { }; } +function fakeStructuredModel(results: { title: string; url: string; snippet?: string }[]) { + return { + withStructuredOutput: vi.fn(() => ({ + invoke: vi.fn(async () => ({ results })), + })), + }; +} + beforeEach(() => { - resolveWebSearchModelId.mockReset(); + createZediChatModel.mockReset(); }); afterEach(() => { - resolveWebSearchModelId.mockReset(); + createZediChatModel.mockReset(); }); describe("webSearchTool", () => { @@ -63,8 +68,11 @@ describe("webSearchTool", () => { }); it("returns the documented fallback when no managed web-search model is configured", async () => { - resolveWebSearchModelId.mockResolvedValueOnce(null); - const raw = await webSearchTool.invoke({ query: "ripgrep" }, ctxConfig()); + const { db } = createMockDb([[], []]); + const raw = await webSearchTool.invoke( + { query: "ripgrep" }, + ctxConfig(db as unknown as Database), + ); const parsed = JSON.parse(raw as string) as { ok: boolean; results: unknown[]; @@ -75,11 +83,61 @@ describe("webSearchTool", () => { expect(parsed.note).toBe("web_search_unavailable"); }); - it("returns an error envelope when model resolution itself throws", async () => { - resolveWebSearchModelId.mockRejectedValueOnce(new Error("db unreachable")); - const raw = await webSearchTool.invoke({ query: "x" }, ctxConfig()); + it("returns an error envelope when model resolution DB throws", async () => { + const db = { + select: vi.fn(() => { + throw new Error("db unreachable"); + }), + }; + const raw = await webSearchTool.invoke({ query: "x" }, ctxConfig(db as unknown as Database)); const parsed = JSON.parse(raw as string) as { ok: boolean; error?: string }; expect(parsed.ok).toBe(false); expect(parsed.error).toMatch(/web_search_model_resolution_failed:db unreachable/); }); + + it("returns structured web hits with stable src ids when LLM succeeds", async () => { + const { db } = createMockDb([[{ id: WIKI_COMPOSE_MODEL_ID }], [{ provider: "openai" }]]); + createZediChatModel.mockResolvedValue( + fakeStructuredModel([ + { + title: "Ripgrep docs", + url: "https://example.com/rg", + snippet: "Fast search", + }, + ]), + ); + + const raw = await webSearchTool.invoke( + { query: "ripgrep", limit: 3 }, + ctxConfig(db as unknown as Database), + ); + const parsed = JSON.parse(raw as string) as { + ok: boolean; + results: Array<{ id: string; kind: string; title: string; url: string; snippet?: string }>; + }; + + expect(parsed.ok).toBe(true); + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0]?.kind).toBe("web"); + expect(parsed.results[0]?.title).toBe("Ripgrep docs"); + expect(parsed.results[0]?.url).toBe("https://example.com/rg"); + expect(parsed.results[0]?.id).toMatch(/^src:[a-f0-9]{64}$/); + }); + + it("wraps LLM failures in a non-throwing error envelope", async () => { + const { db } = createMockDb([[{ id: WIKI_COMPOSE_MODEL_ID }], [{ provider: "google" }]]); + createZediChatModel.mockResolvedValue({ + withStructuredOutput: vi.fn(() => ({ + invoke: vi.fn(async () => { + throw new Error("structured output failed"); + }), + })), + }); + + const raw = await webSearchTool.invoke({ query: "x" }, ctxConfig(db as unknown as Database)); + const parsed = JSON.parse(raw as string) as { ok: boolean; error?: string; results: unknown[] }; + expect(parsed.ok).toBe(false); + expect(parsed.error).toBe("structured output failed"); + expect(parsed.results).toEqual([]); + }); }); diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts index 212b0f54..0e57be38 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts @@ -178,12 +178,13 @@ export async function draftSections( body += chunkContent(chunk); } } catch (err) { - // Per-section failure must not abort the whole Draft. Surface the - // failure as an inline note inside the section body so the user sees - // what happened without losing earlier sections. - // セクション 1 件の失敗で Draft 全体を止めない。エラーは本文に追記。 - const message = err instanceof Error ? err.message : String(err); - body = body || `*(Section draft failed: ${message})*`; + // Per-section failure must not abort the whole Draft. Surface a generic + // inline note so the user knows the section failed without leaking + // provider error details into persisted content (#976). + // セクション 1 件の失敗で Draft 全体を止めない。詳細はログのみ。 + console.error("[draftSections] per-section draft error:", err); + const fallback = "*(Section draft failed. Please retry drafting this section.)*"; + body = body ? `${body.trim()}\n\n${fallback}` : fallback; } const citedIds = collectCitedSourceIds(body, state.approvedResearch, section.sourceIds);