Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions server/api/src/__tests__/agents/core/llm/usageCallback.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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: "" }]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* `resolveWebSearchModelId` unit tests (#1033).
* Priority: fixed Wiki Compose model → env override → cheapest OpenAI/Google.
*/
Comment on lines +1 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consolidated: all three new test files have English-only header docs.

Root cause is a single policy mismatch: file-level comments were added in English only, while repo rules require bilingual Japanese+English comments/documentation for .ts files.

As per coding guidelines, "**/*.{ts,tsx,js,jsx,md}: Comments and documentation must include both Japanese and English text".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/src/__tests__/agents/core/tools/resolveWebSearchModel.test.ts`
around lines 1 - 4, The file-level header comments in
resolveWebSearchModel.test.ts are English-only; update the file header to
include a Japanese translation alongside the existing English text to satisfy
the repo policy for bilingual comments (for .ts files). Edit the top-of-file
comment block used for the `resolveWebSearchModelId` unit tests and prepend or
append the equivalent Japanese description (matching the English meaning, e.g.,
mention fixed Wiki Compose model → env override → cheapest OpenAI/Google) so
both Japanese and English appear in the header.

Source: Coding guidelines

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();
});
});
Original file line number Diff line number Diff line change
@@ -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`.
*/
Comment on lines +1 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add Japanese text to the test header comment to satisfy repo documentation policy.

Lines 1-5 are English-only; this violates the bilingual comment/documentation requirement for .ts files.

Suggested patch
 /**
- * `briefDialogue` unit tests (`#1033`).
- * - Loads page snapshot once and projects briefQuestions into state.
- * - LLM failure degrades to empty questions with `briefDegraded=true`.
+ * `briefDialogue` unit tests (`#1033`).
+ * `briefDialogue` のユニットテスト (`#1033`)。
+ * - Loads page snapshot once and projects briefQuestions into state.
+ * - pageSnapshot を 1 回だけ読み込み、briefQuestions を state に反映する。
+ * - LLM failure degrades to empty questions with `briefDegraded=true`.
+ * - LLM 失敗時は空の質問へフォールバックし、`briefDegraded=true` を設定する。
  */

As per coding guidelines: **/*.{ts,tsx,js,jsx,md} comments and documentation must include both Japanese and English text.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* `briefDialogue` unit tests (#1033).
* - Loads page snapshot once and projects briefQuestions into state.
* - LLM failure degrades to empty questions with `briefDegraded=true`.
*/
/**
* `briefDialogue` unit tests (`#1033`).
* `briefDialogue` のユニットテスト (`#1033`)
* - Loads page snapshot once and projects briefQuestions into state.
* - pageSnapshot 1 回だけ読み込み、briefQuestions state に反映する。
* - LLM failure degrades to empty questions with `briefDegraded=true`.
* - LLM 失敗時は空の質問へフォールバックし、`briefDegraded=true` を設定する。
*/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts`
around lines 1 - 5, The file header comment above the briefDialogue unit tests
is English-only; update the top comment in the briefDialogue test (the header
block in
server/api/src/__tests__/agents/graphs/wikiCompose/nodes/briefDialogue.test.ts)
to include a concise Japanese translation alongside the existing English lines
so the header contains bilingual documentation per policy—preserve the existing
English lines and add equivalent Japanese sentences describing the test purpose
and behavior (e.g., mention loading page snapshot, projecting briefQuestions,
and LLM failure degrading to empty questions with briefDegraded=true).

Source: Coding guidelines

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<typeof actual.createZediChatModel>)),
};
});

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>): 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");
});
});
Loading
Loading