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
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function baseState(): IngestPlannerStateType {
iteration: 1,
queries: [],
sources: [],
evaluation: { score: 0.9, rationale: "ok", missingAspects: [] },
evaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] },
createdAt: "2026-01-01T00:00:00.000Z",
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function defaultMocks() {
wikiSearch.mockImplementation(async () => ({ pendingSources: [] }));
fetchArticles.mockImplementation(async () => ({ pendingSources: [] }));
evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({
lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] },
lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] },
iteration: state.iteration + 1,
phase: "research:evaluated",
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ describe("humanReviewBrief", () => {
},
],
appendToExisting: true,
researchMaxIterations: 4,
});

const update = await humanReviewBrief(state({}), { configurable: {} } as never);
Expand All @@ -97,7 +96,6 @@ describe("humanReviewBrief", () => {
]);
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 () => {
Expand All @@ -111,13 +109,6 @@ describe("humanReviewBrief", () => {
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ function defaultMocks() {
wikiSearch.mockImplementation(async () => ({ pendingSources: [] }));
fetchArticles.mockImplementation(async () => ({ pendingSources: [] }));
evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({
lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] },
lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] },
iteration: state.iteration + 1,
phase: "research:evaluated",
}));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Research loop iteration cap resolution tests.
*/
import { describe, expect, it } from "vitest";
import {
INGEST_RESEARCH_GRAPH_ID,
RESEARCH_SAFETY_MAX_ITERATIONS,
clampIngestMaxIterations,
resolveResearchMaxIterations,
} from "../../../../agents/subgraphs/research/constants.js";
import { WIKI_COMPOSE_GRAPH_ID } from "../../../../agents/graphs/wikiCompose/index.js";

describe("clampIngestMaxIterations", () => {
it("clamps ingest caps to 1..5 with default 3", () => {
expect(clampIngestMaxIterations(undefined)).toBe(3);
expect(clampIngestMaxIterations(99)).toBe(5);
expect(clampIngestMaxIterations(4)).toBe(4);
});
});

describe("resolveResearchMaxIterations", () => {
it("honours ingest graph caps from state", () => {
expect(resolveResearchMaxIterations(INGEST_RESEARCH_GRAPH_ID, 4)).toBe(4);
expect(resolveResearchMaxIterations(INGEST_RESEARCH_GRAPH_ID, 99)).toBe(5);
});

it("uses the safety cap for Wiki Compose graphs regardless of legacy state", () => {
expect(resolveResearchMaxIterations(WIKI_COMPOSE_GRAPH_ID, undefined)).toBe(
RESEARCH_SAFETY_MAX_ITERATIONS,
);
expect(resolveResearchMaxIterations(WIKI_COMPOSE_GRAPH_ID, 3)).toBe(
RESEARCH_SAFETY_MAX_ITERATIONS,
);
expect(resolveResearchMaxIterations("wiki-compose-research", 3)).toBe(
RESEARCH_SAFETY_MAX_ITERATIONS,
);
});
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
/**
* `compileBatch` unit tests. Pure projection node; no LLM. We verify:
* - `exitReason` = "score_threshold" when score >= 0.75.
* - `exitReason` = "max_iterations" otherwise.
* - `exitReason` = "score_threshold" when evaluation is sufficient.
* - `exitReason` = "safety_cap" for Wiki Compose iteration cap hits.
* - `exitReason` = "max_iterations" for ingest iteration cap hits.
* - Batch fields are populated from state.
* - `dispatchCustomEvent` is called via the runnable config.
*/
import { describe, expect, it, vi } from "vitest";

// The dispatch helper requires a proper LangChain callback manager which we
// don't set up here (`compileBatch` is a pure projection). Stub it so the
// node can dispatch into a no-op without a real callback runtime.
// dispatch ヘルパは callback manager 必須なので test では no-op に差し替える。
const { dispatchResearchBatch } = vi.hoisted(() => ({
dispatchResearchBatch: vi.fn(async () => undefined),
}));
Expand All @@ -20,9 +16,15 @@ vi.mock("../../../../../agents/subgraphs/research/nodes/shared/dispatchSseCustom
dispatchResearchIteration: vi.fn(),
}));

import { compileBatch } from "../../../../../agents/subgraphs/research/nodes/compileBatch.js";
import {
compileBatch,
resolveResearchExitReason,
} from "../../../../../agents/subgraphs/research/nodes/compileBatch.js";
import { GRAPH_CONTEXT_CONFIG_KEY } from "../../../../../agents/core/types/graphContext.js";
import type { GraphContext } from "../../../../../agents/core/types/graphContext.js";
import type { ResearchLoopStateType } from "../../../../../agents/subgraphs/research/state.js";
import type { ResearchBatch } from "../../../../../agents/subgraphs/research/types.js";
import type { Database } from "../../../../../types/index.js";

function state(overrides: Partial<ResearchLoopStateType>): ResearchLoopStateType {
return {
Expand All @@ -47,18 +49,75 @@ function state(overrides: Partial<ResearchLoopStateType>): ResearchLoopStateType
};
}

function configForGraph(graphId: string) {
const ctx: GraphContext = {
threadId: "t",
sessionId: "t",
userId: "user-1",
pageId: "page-1",
graphId,
backend: "zedi_managed",
tier: "free",
db: {} as Database,
feature: "wiki_compose:research",
userEmail: null,
contentLocale: "ja",
};
return { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } };
}

describe("resolveResearchExitReason", () => {
it("returns score_threshold when sufficient is true", () => {
expect(
resolveResearchExitReason(
state({
lastEvaluation: { score: 0.2, sufficient: true, rationale: "ok", missingAspects: [] },
}),
"wiki-compose",
),
).toBe("score_threshold");
});

it("returns safety_cap for Wiki Compose iteration cap hits", () => {
expect(
resolveResearchExitReason(
state({
lastEvaluation: {
score: 0.2,
sufficient: false,
rationale: "weak",
missingAspects: ["x"],
},
}),
"wiki-compose",
),
).toBe("safety_cap");
});

it("returns max_iterations for ingest iteration cap hits", () => {
expect(
resolveResearchExitReason(
state({
lastEvaluation: {
score: 0.2,
sufficient: false,
rationale: "weak",
missingAspects: ["x"],
},
}),
"ingest-planner",
),
).toBe("max_iterations");
});
});

describe("compileBatch", () => {
it("uses score_threshold when last score >= 0.75", async () => {
const dispatcher = vi.fn();
const config = {
configurable: { callbacks: undefined },
callbacks: { handlers: [], inheritableHandlers: [], dispatchCustomEvent: dispatcher },
};
it("uses score_threshold when last evaluation is sufficient", async () => {
const update = await compileBatch(
state({ lastEvaluation: { score: 0.85, rationale: "ok", missingAspects: [] } }),
// Loose config type — node only reads callback runtime, which LangGraph
// wires through the surrounding `streamEvents` / `invoke` call.
config as never,
state({
lastEvaluation: { score: 0.85, sufficient: true, rationale: "ok", missingAspects: [] },
}),
configForGraph("wiki-compose") as never,
);
expect(update.exitReason).toBe("score_threshold");
const batches = update.batches as ResearchBatch[] | undefined;
Expand All @@ -67,19 +126,32 @@ describe("compileBatch", () => {
expect(batches?.[0]?.iteration).toBe(2);
});

it("uses max_iterations when no eval or score below threshold", async () => {
it("uses safety_cap for Wiki Compose when evaluation is insufficient", async () => {
const update = await compileBatch(
state({ lastEvaluation: { score: 0.5, rationale: "weak", missingAspects: ["x"] } }),
{ configurable: {} } as never,
state({
lastEvaluation: { score: 0.5, sufficient: false, rationale: "weak", missingAspects: ["x"] },
}),
configForGraph("wiki-compose-research") as never,
);
expect(update.exitReason).toBe("safety_cap");
});

it("uses max_iterations for ingest when evaluation is insufficient", async () => {
const update = await compileBatch(
state({
lastEvaluation: { score: 0.5, sufficient: false, rationale: "weak", missingAspects: ["x"] },
}),
configForGraph("ingest-planner") as never,
);
expect(update.exitReason).toBe("max_iterations");
});

it("handles null evaluation gracefully", async () => {
const update = await compileBatch(state({ lastEvaluation: null }), {
configurable: {},
} as never);
expect(update.exitReason).toBe("max_iterations");
const update = await compileBatch(
state({ lastEvaluation: null }),
configForGraph("wiki-compose") as never,
);
expect(update.exitReason).toBe("safety_cap");
const batches = update.batches as ResearchBatch[] | undefined;
expect(batches?.[0]?.evaluation).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ vi.mock("../../../../../agents/subgraphs/research/nodes/shared/dispatchSseCustom
}));

import { planQueries } from "../../../../../agents/subgraphs/research/nodes/planQueries.js";
import {
INGEST_RESEARCH_GRAPH_ID,
RESEARCH_SAFETY_MAX_ITERATIONS,
} from "../../../../../agents/subgraphs/research/constants.js";
import { WIKI_COMPOSE_GRAPH_ID } from "../../../../../agents/graphs/wikiCompose/index.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";
Expand Down Expand Up @@ -93,9 +98,25 @@ afterEach(() => {
describe("planQueries — additional research detection", () => {
const config = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: fakeContext() } };

it("clamps maxIterations to 1..5 (default 3)", async () => {
it("uses the safety cap for Wiki Compose even when legacy state has maxIterations: 3", async () => {
const ctx = fakeContext();
ctx.graphId = WIKI_COMPOSE_GRAPH_ID;
const wikiConfig = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } };
const update = await planQueries(state({ maxIterations: 3 }), wikiConfig as never);
expect(update.maxIterations).toBe(RESEARCH_SAFETY_MAX_ITERATIONS);
});

it("uses the safety cap when graph is standalone research", async () => {
const update = await planQueries(state({ maxIterations: 99 }), config as never);
expect(update.maxIterations).toBe(5);
expect(update.maxIterations).toBe(RESEARCH_SAFETY_MAX_ITERATIONS);
});

it("honours ingest graph caps from state", async () => {
const ctx = fakeContext();
ctx.graphId = INGEST_RESEARCH_GRAPH_ID;
const ingestConfig = { configurable: { [GRAPH_CONTEXT_CONFIG_KEY]: ctx } };
const update = await planQueries(state({ maxIterations: 4 }), ingestConfig as never);
expect(update.maxIterations).toBe(4);
});

it("consumes state.additionalRequest and seeds carried-over sources", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("shouldRefine", () => {
state({
iteration: 1,
maxIterations: 5,
lastEvaluation: { score: 0.75, rationale: "ok", missingAspects: [] },
lastEvaluation: { score: 0.75, sufficient: true, rationale: "ok", missingAspects: [] },
}),
),
).toBe("compile");
Expand All @@ -49,7 +49,7 @@ describe("shouldRefine", () => {
state({
iteration: 1,
maxIterations: 5,
lastEvaluation: { score: 0.95, rationale: "great", missingAspects: [] },
lastEvaluation: { score: 0.95, sufficient: true, rationale: "great", missingAspects: [] },
}),
),
).toBe("compile");
Expand All @@ -61,7 +61,12 @@ describe("shouldRefine", () => {
state({
iteration: 1,
maxIterations: 3,
lastEvaluation: { score: 0.5, rationale: "weak", missingAspects: ["x"] },
lastEvaluation: {
score: 0.5,
sufficient: false,
rationale: "weak",
missingAspects: ["x"],
},
}),
),
).toBe("refine");
Expand All @@ -73,7 +78,12 @@ describe("shouldRefine", () => {
state({
iteration: 3,
maxIterations: 3,
lastEvaluation: { score: 0.4, rationale: "weak", missingAspects: ["x", "y"] },
lastEvaluation: {
score: 0.4,
sufficient: false,
rationale: "weak",
missingAspects: ["x", "y"],
},
}),
),
).toBe("compile");
Expand All @@ -83,6 +93,18 @@ describe("shouldRefine", () => {
expect(shouldRefine(state({ iteration: 4, maxIterations: 3 }))).toBe("compile");
});

it("compiles when sufficient is true even if score is below threshold", () => {
expect(
shouldRefine(
state({
iteration: 1,
maxIterations: 5,
lastEvaluation: { score: 0.4, sufficient: true, rationale: "ok", missingAspects: [] },
}),
),
).toBe("compile");
});

it("refines when there's no evaluation yet and iterations remain", () => {
// Defensive: if evaluate_sufficiency hasn't run, treat as "not enough yet".
// evaluation 未走の保険 — まだ充足してないとみなす。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe("researchLoopSubgraph — interrupt at human_review_research", () => {
wikiSearch.mockImplementation(async () => ({ pendingSources: [] }));
fetchArticles.mockImplementation(async () => ({ pendingSources: [] }));
evaluateSufficiency.mockImplementation(async (state, _c) => ({
lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] },
lastEvaluation: { score: 0.9, sufficient: true, rationale: "ok", missingAspects: [] },
iteration: state.iteration + 1,
phase: "research:evaluated",
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ describe("researchLoopSubgraph — autonomous loop", () => {
evaluateSufficiency.mockImplementation(async (state, _config) => {
evaluatedTimes += 1;
return {
lastEvaluation: { score: 0.1, rationale: "weak", missingAspects: ["x"] },
lastEvaluation: { score: 0.1, sufficient: false, rationale: "weak", missingAspects: ["x"] },
iteration: state.iteration + 1,
phase: "research:evaluated",
};
Expand Down Expand Up @@ -194,7 +194,7 @@ describe("researchLoopSubgraph — autonomous loop", () => {
wikiSearch.mockImplementation(async () => ({ pendingSources: [] }));
fetchArticles.mockImplementation(async () => ({ pendingSources: [] }));
evaluateSufficiency.mockImplementation(async (state, _c) => ({
lastEvaluation: { score: 0.9, rationale: "great", missingAspects: [] },
lastEvaluation: { score: 0.9, sufficient: true, rationale: "great", missingAspects: [] },
iteration: state.iteration + 1,
phase: "research:evaluated",
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe("researchLoopSubgraph — all LLM calls go through ZediChatModel", () =
const score = evaluateCall >= 2 ? 0.9 : 0.1;
return fakeModel(async () => ({
score,
sufficient: score >= 0.75,
rationale: "auto",
missingAspects: score < 0.75 ? ["x"] : [],
}));
Expand Down
Loading
Loading