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
@@ -1,5 +1,5 @@
/**
* Wiki Compose orchestrator graph (#950) — wiring + interrupt tests.
* Wiki Compose orchestrator graph (#950, #953) — wiring + interrupt tests.
*
* 受け入れ条件 #1 / #6 / 技術 #1:
* - `wikiComposeGraph` が P1 subgraph を組み込んでいる (channels 共有で表現)
Expand Down Expand Up @@ -285,4 +285,76 @@ describe("wikiComposeGraph — orchestrator wiring", () => {
expect(finalState.completion?.markdown).toMatch(/Overview/);
expect(finalState.completion?.markdown).toMatch(/Details/);
});

it("skips research when Brief emits zero questions (P5)", async () => {
briefDialogue.mockImplementation(async () => ({
briefQuestions: [],
pageSnapshot: { pageId: "page-1", title: "Self-evident Title", body: "", hasContent: false },
phase: "brief:await_user",
}));

const checkpointer = new MemorySaver();
const runner = new GraphRunner();
const ctx = fakeContext("thread-skip-research");

await runner.invoke(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{ kind: "input", value: { messages: [{ role: "user", content: "title: Obvious" }] } },
);

const afterBrief = await runner.resume(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{ answers: [], appendToExisting: false },
);

expect(afterBrief.status).toBe("interrupted");
expect(planQueries).not.toHaveBeenCalled();
expect(compileBatch).not.toHaveBeenCalled();
expect(structureDialogue).toHaveBeenCalledTimes(1);
});

it("halts at conflict_resolution when many sources are rejected (P5)", async () => {
webSearch.mockImplementation(async () => ({
pendingSources: [
{ id: "src:a", kind: "web", title: "A", url: "https://a/" },
{ id: "src:b", kind: "web", title: "B", url: "https://b/" },
{ id: "src:c", kind: "web", title: "C", url: "https://c/" },
],
}));

const checkpointer = new MemorySaver();
const runner = new GraphRunner();
const ctx = fakeContext("thread-conflict");

await runner.invoke(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{ kind: "input", value: { messages: [{ role: "user", content: "title: Hello" }] } },
);
await runner.resume(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{ answers: [], appendToExisting: false },
);

const conflictHalt = await runner.resume(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{
approvedSourceIds: ["src:a"],
rejectedSourceIds: ["src:b", "src:c"],
},
);

expect(conflictHalt.status).toBe("interrupted");
const interruptState = conflictHalt.output as {
__interrupt__?: Array<{ value: { kind?: string } }>;
};
expect(interruptState.__interrupt__?.[0]?.value?.kind).toBe("conflict_resolution");
expect(structureDialogue).not.toHaveBeenCalled();

const afterConflict = await runner.resume(
{ graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 },
{ acknowledged: true },
);
expect(afterConflict.status).toBe("interrupted");
expect(structureDialogue).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Wiki Compose P5 routing predicates (#953).
* Wiki Compose P5 ルーティング述語のテスト (#953)。
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { describe, expect, it } from "vitest";
import {
routeAfterBrief,
routeAfterResearch,
shouldResolveResearchConflicts,
} from "../../../../agents/graphs/wikiCompose/routing.js";
import type { WikiComposeStateType } from "../../../../agents/graphs/wikiCompose/state.js";

function minimalState(overrides: Partial<WikiComposeStateType> = {}): WikiComposeStateType {
return {
messages: [],
phase: "init",
pageId: "page-1",
userId: "user-1",
chatSeed: null,
pageSnapshot: null,
briefQuestions: [],
brief: null,
briefDegraded: false,
iteration: 0,
maxIterations: 3,
queries: [],
pendingSources: [],
lastEvaluation: null,
exitReason: null,
batches: [],
approvedResearch: [],
rejectedResearch: [],
additionalRequest: null,
researchConflicts: [],
outlineProposal: [],
approvedOutline: null,
draftedSections: [],
completion: null,
...overrides,
};
}

describe("routeAfterBrief", () => {
it("routes to skip_research when Brief emitted zero questions", () => {
expect(routeAfterBrief(minimalState({ briefQuestions: [] }))).toBe("skip_research");
});

it("routes to skip_research when chatSeed carries a pre-approved outline", () => {
expect(
routeAfterBrief(
minimalState({
briefQuestions: [{ id: "q1", question: "Scope?", options: [], required: false }],
chatSeed: { outline: "## Intro\n- point", conversationText: "hi" },
}),
),
).toBe("skip_research");
});

it("routes to research when Brief is empty due to LLM degradation flag", () => {
expect(routeAfterBrief(minimalState({ briefQuestions: [], briefDegraded: true }))).toBe(
"research",
);
});

it("routes to research when Brief has questions and no chat outline seed", () => {
expect(
routeAfterBrief(
minimalState({
briefQuestions: [{ id: "q1", question: "Audience?", options: [], required: true }],
chatSeed: null,
}),
),
).toBe("research");
});
});

describe("routeAfterResearch / shouldResolveResearchConflicts", () => {
it("detects conflict when ≥2 rejected and ≥1 approved", () => {
const state = minimalState({
approvedResearch: [{ id: "a", kind: "web", title: "A" }],
rejectedResearch: [
{ id: "b", kind: "web", title: "B" },
{ id: "c", kind: "web", title: "C" },
],
});
expect(shouldResolveResearchConflicts(state)).toBe(true);
expect(routeAfterResearch(state)).toBe("conflict_resolution");
});

it("routes to structure when rejections are below threshold", () => {
const state = minimalState({
approvedResearch: [{ id: "a", kind: "web", title: "A" }],
rejectedResearch: [{ id: "b", kind: "web", title: "B" }],
});
expect(routeAfterResearch(state)).toBe("structure");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Wiki maintenance graph (#953) — wiring + scan node tests.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const { scanBrokenLinks, scanStubPages } = vi.hoisted(() => ({
scanBrokenLinks: vi.fn(),
scanStubPages: vi.fn(),
}));

vi.mock("../../../../agents/graphs/wikiMaintenance/nodes/index.js", async () => {
const real = await vi.importActual<
typeof import("../../../../agents/graphs/wikiMaintenance/nodes/index.js")
>("../../../../agents/graphs/wikiMaintenance/nodes/index.js");
return { ...real, scanBrokenLinks, scanStubPages };
});

import { GraphRunner } from "../../../../agents/runner/graphRunner.js";
import { __resetRegistryForTests } from "../../../../agents/registry/graphRegistry.js";
import {
WIKI_MAINTENANCE_GRAPH_ID,
registerWikiMaintenanceGraph,
} from "../../../../agents/graphs/wikiMaintenance/index.js";
import type { GraphContext } from "../../../../agents/core/types/graphContext.js";
import type { Database } from "../../../../types/index.js";

function fakeContext(threadId: string): GraphContext {
return {
threadId,
sessionId: threadId,
userId: "user-1",
pageId: "page-1",
graphId: WIKI_MAINTENANCE_GRAPH_ID,
backend: "zedi_managed",
tier: "free",
db: {} as Database,
feature: "wiki_maintenance:test",
userEmail: null,
};
}

describe("wikiMaintenanceGraph", () => {
beforeEach(() => {
__resetRegistryForTests();
registerWikiMaintenanceGraph();
scanBrokenLinks.mockReset();
scanStubPages.mockReset();
scanBrokenLinks.mockImplementation(async () => ({
brokenLinkFindings: [
{
rule: "broken_link",
severity: "error",
pageIds: ["p1", "p2"],
detail: { sourceId: "p1" },
},
],
phase: "maintenance:broken_links_scanned",
}));
scanStubPages.mockImplementation(async () => ({
stubPageFindings: [
{
rule: "stub_page",
severity: "info",
pageIds: ["p3"],
detail: { title: "Draft" },
},
],
phase: "maintenance:stub_pages_scanned",
}));
});

afterEach(() => {
__resetRegistryForTests();
});

it("runs scan → plan and completes with a maintenance plan", async () => {
const runner = new GraphRunner();
const result = await runner.invoke(
{
graphId: WIKI_MAINTENANCE_GRAPH_ID,
context: fakeContext("maint-1"),
checkpointer: false,
recursionLimit: 20,
},
{ kind: "input", value: {} },
);

expect(result.status).toBe("completed");
expect(scanBrokenLinks).toHaveBeenCalledTimes(1);
expect(scanStubPages).toHaveBeenCalledTimes(1);

const out = result.output as {
maintenancePlan?: { brokenLinkCount: number; stubPageCount: number; findings: unknown[] };
phase?: string;
};
expect(out.phase).toBe("maintenance:planned");
expect(out.maintenancePlan?.brokenLinkCount).toBe(1);
expect(out.maintenancePlan?.stubPageCount).toBe(1);
expect(out.maintenancePlan?.findings).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ describe("projectComposeStateValues", () => {
expect(projection.phase).toBe("research");
});

it("projects a conflict_resolution interrupt (#953)", () => {
const projection = projectComposeStateValues({
approvedResearch: [{ id: "src:a", kind: "web", title: "A" }],
__interrupt__: [
{
value: {
kind: "conflict_resolution",
conflicts: {
approved: [{ id: "src:a", title: "A" }],
rejected: [
{ id: "src:b", title: "B" },
{ id: "src:c", title: "C" },
],
rationale: "Mixed approval",
},
},
},
],
});
expect(projection.phase).toBe("conflict");
expect(projection.researchConflictSummary).toMatchObject({ rationale: "Mixed approval" });
expect(projection.approvedSources).toHaveLength(1);
});

it("projects completion markdown from checkpoint values", () => {
const projection = projectComposeStateValues({
phase: "completed",
Expand Down
2 changes: 1 addition & 1 deletion server/api/src/agents/core/types/sseEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export interface SseResearchBatchEvent {
export interface SseComposePhaseEvent {
type: "compose_phase";
/** Phase name (matches state.phase). */
phase: "brief" | "research" | "structure" | "draft" | "completed";
phase: "brief" | "research" | "conflict" | "structure" | "draft" | "completed";
/** Lifecycle hint within the phase. */
status: "entered" | "completed";
}
Expand Down
10 changes: 10 additions & 0 deletions server/api/src/agents/graphs/wikiCompose/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,20 @@ export type {
OutlineSection,
PageSnapshot,
WikiComposeInterruptPayload,
ResearchConflictSummary,
} from "./types.js";
export {
briefResumeSchema,
type BriefResumeParsed,
outlineResumeSchema,
type OutlineResumeParsed,
conflictResumeSchema,
type ConflictResumeParsed,
} from "./resumeSchemas.js";
export {
routeAfterBrief,
routeAfterResearch,
shouldResolveResearchConflicts,
type BriefRoute,
type ResearchRoute,
} from "./routing.js";
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export async function briefDialogue(
// `structured.invoke` returns the zod input type (pre-default), so we
// accept it as-is and apply fallbacks at the projection step below.
let raw: z.input<typeof briefQuestionsSchema>;
let briefDegraded = false;
try {
raw = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
Expand All @@ -142,10 +143,11 @@ export async function briefDialogue(
]);
} catch {
// Defensive fallback: if the LLM call fails, emit an empty Brief so the
// user can still proceed straight to research. The orchestrator must not
// become unstartable just because of a transient model error.
// LLM 失敗時は Brief 0 件で先へ進ませる安全策
// user can still proceed straight to research. `briefDegraded` prevents
// `routeAfterBrief` from skipping research on this path (#953).
// LLM 失敗時は Brief 0 件で先へ進ませる。`briefDegraded` で調査スキップと区別する
raw = { questions: [] };
briefDegraded = true;
}

const briefQuestions: BriefQuestion[] = raw.questions.map((q) => ({
Expand All @@ -163,6 +165,7 @@ export async function briefDialogue(
return {
pageSnapshot: snapshot,
briefQuestions,
briefDegraded,
phase: "brief:await_user",
};
}
Loading
Loading