From 02fb48eff9ea3c13cffe5c70d6d75c6131721168 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 05:52:26 +0000 Subject: [PATCH 1/2] refactor(wiki-compose): apply thermo-nuclear review fixes - Align frontend backend resolution with Google-fixed Wiki Compose model - Extract session reducer module; slim useWikiComposeSession hook - Consolidate auto-start via startPolicy and canRetryStart - Deduplicate compose session locale prep on API routes - Remove duplicate props and dead outline preview ternary Co-authored-by: Akimasa Sugai --- .../routes/composeSessionRunLocale.test.ts | 44 ++ .../api/src/routes/composeSessionRunLocale.ts | 66 ++ server/api/src/routes/composeSessions.ts | 36 +- src/hooks/useWikiComposeSession.test.ts | 7 + src/hooks/useWikiComposeSession.ts | 613 +++--------------- .../wikiCompose/resolveComposeBackend.test.ts | 48 +- src/lib/wikiCompose/resolveComposeBackend.ts | 35 +- .../wikiCompose/wikiComposeSessionReducer.ts | 424 ++++++++++++ src/pages/WikiComposePage.tsx | 69 +- 9 files changed, 706 insertions(+), 636 deletions(-) create mode 100644 server/api/src/__tests__/routes/composeSessionRunLocale.test.ts create mode 100644 server/api/src/routes/composeSessionRunLocale.ts create mode 100644 src/lib/wikiCompose/wikiComposeSessionReducer.ts diff --git a/server/api/src/__tests__/routes/composeSessionRunLocale.test.ts b/server/api/src/__tests__/routes/composeSessionRunLocale.test.ts new file mode 100644 index 00000000..04c47414 --- /dev/null +++ b/server/api/src/__tests__/routes/composeSessionRunLocale.test.ts @@ -0,0 +1,44 @@ +/** + * Unit tests for compose session locale preparation helper. + */ +import { describe, expect, it } from "bun:test"; +import { + prepareComposeRunFromRequest, + resolveComposeSessionContentLocale, +} from "../../routes/composeSessionRunLocale.js"; + +describe("prepareComposeRunFromRequest", () => { + it("strips contentLocale from graph input and builds metadata patch on first run", () => { + const prep = prepareComposeRunFromRequest( + { composeSeed: { outline: "a", conversationText: "b" } }, + { contentLocale: "en", chatSeed: { outline: "a", conversationText: "b" } }, + "ja-JP", + "ja", + ); + expect(prep.contentLocale).toBe("en"); + expect(prep.graphInput).toEqual({ chatSeed: { outline: "a", conversationText: "b" } }); + expect(prep.metadataUpdate).toEqual({ + composeSeed: { outline: "a", conversationText: "b" }, + contentLocale: "en", + }); + }); + + it("skips metadata patch when locale is already persisted", () => { + const prep = prepareComposeRunFromRequest( + { contentLocale: "ja" }, + { contentLocale: "en" }, + null, + "ja", + ); + expect(prep.contentLocale).toBe("ja"); + expect(prep.metadataUpdate).toBeUndefined(); + }); +}); + +describe("resolveComposeSessionContentLocale", () => { + it("delegates to session metadata when present", () => { + expect(resolveComposeSessionContentLocale({ contentLocale: "en" }, null, "ja", "ja")).toBe( + "en", + ); + }); +}); diff --git a/server/api/src/routes/composeSessionRunLocale.ts b/server/api/src/routes/composeSessionRunLocale.ts new file mode 100644 index 00000000..50dab266 --- /dev/null +++ b/server/api/src/routes/composeSessionRunLocale.ts @@ -0,0 +1,66 @@ +/** + * Shared locale preparation for compose session run / projection routes. + * compose セッション run / projection ルート向けのロケール準備を共通化する。 + */ +import { + readContentLocaleFromSessionMetadata, + resolveSessionContentLocale, + stripContentLocaleFromGraphInput, + type ComposeContentLocale, +} from "../agents/core/composeLocale.js"; + +/** Result of preparing a `POST /run` request for LangGraph execution. */ +export type ComposeRunLocalePrep = { + contentLocale: ComposeContentLocale; + graphInput: unknown; + /** Metadata blob to persist on claim when locale is not yet stored. */ + metadataUpdate: Record | undefined; +}; + +function mergeSessionMetadataWithLocale( + metadata: unknown, + contentLocale: ComposeContentLocale, +): Record { + const base = + metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? { ...(metadata as Record) } + : {}; + return { ...base, contentLocale }; +} + +/** + * Resolve content locale, strip it from graph input, and build metadata patch for first run. + * contentLocale を解決し graph input から除去、初回 run 用 metadata 更新を組み立てる。 + */ +export function prepareComposeRunFromRequest( + sessionMetadata: unknown, + rawInput: unknown, + acceptLanguage: string | undefined | null, + fallback: ComposeContentLocale = "ja", +): ComposeRunLocalePrep { + const contentLocale = resolveSessionContentLocale( + sessionMetadata, + rawInput, + acceptLanguage, + fallback, + ); + const shouldPersistLocale = !readContentLocaleFromSessionMetadata(sessionMetadata); + const metadataUpdate = shouldPersistLocale + ? mergeSessionMetadataWithLocale(sessionMetadata, contentLocale) + : undefined; + const graphInput = stripContentLocaleFromGraphInput(rawInput ?? {}); + return { contentLocale, graphInput, metadataUpdate }; +} + +/** + * Resolve content locale for read / resume paths (no graph input or metadata patch). + * GET / resume 向けに contentLocale のみ解決する。 + */ +export function resolveComposeSessionContentLocale( + sessionMetadata: unknown, + rawInput: unknown | null, + acceptLanguage: string | undefined | null, + fallback: ComposeContentLocale = "ja", +): ComposeContentLocale { + return resolveSessionContentLocale(sessionMetadata, rawInput, acceptLanguage, fallback); +} diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 058be247..3763a8cf 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -61,15 +61,14 @@ import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js"; import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js"; import { WIKI_MAINTENANCE_GRAPH_ID } from "../agents/graphs/wikiMaintenance/index.js"; -import { - readContentLocaleFromSessionMetadata, - resolveSessionContentLocale, - stripContentLocaleFromGraphInput, - type ComposeContentLocale, -} from "../agents/core/composeLocale.js"; +import type { ComposeContentLocale } from "../agents/core/composeLocale.js"; import type { AppEnv } from "../types/index.js"; import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js"; import { loadComposeSessionProjection } from "./composeSessionProjection.js"; +import { + prepareComposeRunFromRequest, + resolveComposeSessionContentLocale, +} from "./composeSessionRunLocale.js"; /** * Translate the documented `body.input.kind === "additional_research"` shape @@ -251,7 +250,7 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { if (!row) throw new HTTPException(404, { message: "Session not found" }); const tier = await getUserTier(userId, db); - const contentLocale = resolveSessionContentLocale( + const contentLocale = resolveComposeSessionContentLocale( row.metadata, null, c.req.header("accept-language"), @@ -343,24 +342,16 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( throw err; } - const acceptLanguage = c.req.header("accept-language"); - const contentLocale = resolveSessionContentLocale( + const { + contentLocale, + graphInput, + metadataUpdate: metadataWithLocale, + } = prepareComposeRunFromRequest( session.metadata, body.input, - acceptLanguage, + c.req.header("accept-language"), "ja", ); - const shouldPersistLocale = !readContentLocaleFromSessionMetadata(session.metadata); - const metadataWithLocale = shouldPersistLocale - ? { - ...(session.metadata && - typeof session.metadata === "object" && - !Array.isArray(session.metadata) - ? { ...(session.metadata as Record) } - : {}), - contentLocale, - } - : undefined; // Atomically claim the session (and persist `contentLocale` when needed) so // concurrent POST /run cannot double-bill and a failed follow-up write cannot @@ -421,7 +412,6 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( // checkpoint 保存・再開を有効化する。テスト / CI では未設定なので `false` // を返し、LangGraph の checkpoint 機構を無効化したまま smoke-test で走る。 const checkpointer = await resolveCheckpointerForRun(); - const graphInput = stripContentLocaleFromGraphInput(body.input ?? {}); try { await send(startedEvent(id, session.graphId, session.phase)); @@ -556,7 +546,7 @@ app.patch("/:pageId/compose-sessions/:id/resume", authRequired, rateLimit(), asy // Resume relies on the checkpointer to fetch the suspended thread; production // routes load `PostgresSaver` here, tests/smoke runs get `false`. const checkpointer = await resolveCheckpointerForRun(); - const contentLocale = resolveSessionContentLocale( + const contentLocale = resolveComposeSessionContentLocale( session.metadata, null, c.req.header("accept-language"), diff --git a/src/hooks/useWikiComposeSession.test.ts b/src/hooks/useWikiComposeSession.test.ts index ab89b4fa..f31f6699 100644 --- a/src/hooks/useWikiComposeSession.test.ts +++ b/src/hooks/useWikiComposeSession.test.ts @@ -31,6 +31,13 @@ vi.mock("@/lib/wikiCompose/resolveComposeContentLocale", () => ({ resolveComposeContentLocale: () => "ja" as const, })); +vi.mock("@/hooks/useInitialComposeBackend", () => ({ + useInitialComposeBackend: () => ({ + backend: "zedi_managed" as const, + isResolved: true, + }), +})); + import { useWikiComposeSession } from "./useWikiComposeSession"; import type { ComposeSseEvent } from "@/lib/wikiCompose/types"; diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index f3f341f8..68fd62ef 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -1,15 +1,5 @@ /** - * `useWikiComposeSession` — React state machine for one Wiki Compose session - * (#950). - * - * Compose 1 セッションのフロント側 state machine。SSE で来るイベントを - * pattern match し、Brief 質問 / 調査バッチ / アウトライン / セクション本文 - * といったフェーズ固有の slice を再アセンブルする。`WikiComposePage` は本 - * フックの戻り値を読みつつ、`submitBrief` / `submitResearchApproval` / - * `submitOutline` を呼ぶことで graph を次フェーズへ進める。 - * - * Owns the wire-level wiring; UI components stay pure. Critically, the hook - * does NOT navigate or persist — it only reflects what the SSE stream says. + * `useWikiComposeSession` — React state machine for one Wiki Compose session (#950). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -19,539 +9,77 @@ import { resumeSession, runSession, } from "@/lib/wikiCompose/composeService"; -import i18n from "@/i18n"; import type { ComposeExecutionBackend } from "@/lib/wikiCompose/backends"; -import { resolveComposeContentLocale } from "@/lib/wikiCompose/resolveComposeContentLocale"; import type { ComposeNavigationSeed } from "@/lib/wikiCompose/navigation"; +import { + hydrateComposeFromProjection, + INITIAL_WIKI_COMPOSE_SESSION_STATE, + parseComposeSeedFromMetadata, + reduceComposeResumeOutput, + reduceComposeSseEvent, + withContentLocale, + type ComposeActivity, + type ComposePhase, + type WikiComposeSessionState, +} from "@/lib/wikiCompose/wikiComposeSessionReducer"; +import { useInitialComposeBackend } from "@/hooks/useInitialComposeBackend"; import type { BriefAnswer, - BriefQuestion, - ComposeInterruptPayload, ComposeSession, - ComposeSessionStatus, - ComposeSessionUiProjection, - ComposeSseEvent, DraftedSection, OutlineSection, - PageSnapshot, - ResearchBatch, - ResearchConflictSummary, - ResearchSource, } from "@/lib/wikiCompose/types"; +import type { ComposeSseEvent } from "@/lib/wikiCompose/types"; + +export type { ComposeActivity, ComposePhase, WikiComposeSessionState }; /** - * UI phase for Wiki Compose session progression. - * Wiki Compose セッション進行を表す UI フェーズ。 + * When the hook should call `start()` automatically. + * `start()` を自動実行するタイミング。 */ -export type ComposePhase = "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; - -/** Activity log entry surfaced in the right pane's ActivitySection. */ -export interface ComposeActivity { - id: string; - /** ISO timestamp. */ - at: string; - /** Human-readable label for the activity row. */ - label: string; - /** Optional secondary line (status, tool name, etc.). */ - detail?: string; - /** Lifecycle hint so the UI can render spinners / checkmarks. */ - status?: "started" | "completed" | "info" | "error"; -} - -/** Aggregate state surfaced to the UI. */ -export interface WikiComposeSessionState { - session: ComposeSession | null; - status: ComposeSessionStatus | "idle"; - phase: ComposePhase; - /** Brief phase question cards (from interrupt). */ - briefQuestions: BriefQuestion[]; - /** Page snapshot (loaded at session start). */ - pageSnapshot: PageSnapshot | null; - /** Latest research batch from the human-review interrupt. */ - latestBatch: ResearchBatch | null; - /** Pending sources at the research interrupt. */ - pendingSources: ResearchSource[]; - /** Approved sources after research resume. */ - approvedSources: ResearchSource[]; - /** P5 conflict-resolution interrupt payload (#953). */ - researchConflictSummary: ResearchConflictSummary | null; - /** Proposed outline from the structure interrupt. */ - outlineProposal: OutlineSection[]; - /** Drafted section bodies — keyed by sectionId. */ - draftedSections: Record; - /** While streaming a section, this id is set; null between sections. */ - streamingSectionId: string | null; - /** Per-section running token buffer while the section is mid-stream. */ - sectionBuffers: Record; - /** Activity timeline (newest last). */ - activity: ComposeActivity[]; - /** Final markdown if the session completed. */ - completedMarkdown: string | null; - /** Last error message (set on failure). */ - error: string | null; - /** True while an SSE stream is open. */ - isStreaming: boolean; -} - -const INITIAL_STATE: WikiComposeSessionState = { - session: null, - status: "idle", - phase: "brief", - briefQuestions: [], - pageSnapshot: null, - latestBatch: null, - pendingSources: [], - approvedSources: [], - researchConflictSummary: null, - outlineProposal: [], - draftedSections: {}, - streamingSectionId: null, - sectionBuffers: {}, - activity: [], - completedMarkdown: null, - error: null, - isStreaming: false, -}; +export type ComposeStartPolicy = "never" | "on-mount" | "when-backend-ready"; /** Args accepted by the hook. */ export interface UseWikiComposeSessionArgs { pageId: string; - /** Existing session to resume; pass `null` to create a fresh session on start. */ sessionId: string | null; - /** - * 初回 `POST /run` に渡す graph input(例: `chatSeed`)。 - * Initial graph input for the first `POST /run` (e.g. `{ chatSeed }`). - */ initialInput?: Record; - /** - * チャット由来 seed。セッション行 `metadata` にも保存する。 - * Chat-origin seed; also persisted on the session row metadata. - */ composeSeed?: ComposeNavigationSeed; - /** Auto-start the first `run` when the session is created. Default `true`. */ - autoStart?: boolean; /** - * 実行 backend(セッション作成時に固定)。省略時は `zedi_managed`。 - * Execution backend fixed at session create; defaults to `zedi_managed`. + * @deprecated Prefer {@link startPolicy}. When set without `startPolicy`, `false` → `never`, `true` → `on-mount`. */ + autoStart?: boolean; + /** When to auto-invoke `start()`. Default `on-mount`. */ + startPolicy?: ComposeStartPolicy; backend?: ComposeExecutionBackend; } /** Hook return shape. */ export interface UseWikiComposeSessionReturn extends WikiComposeSessionState { - /** Start a new session (or resume the existing one) and begin streaming. */ start: () => Promise; - /** Submit Brief answers and continue streaming. */ submitBrief: (input: { answers: BriefAnswer[]; appendToExisting?: boolean; researchMaxIterations?: number; }) => Promise; - /** Submit research source approval (Approve/Reject) and continue streaming. */ submitResearchApproval: (input: { approvedSourceIds: string[]; rejectedSourceIds?: string[]; note?: string; }) => Promise; - /** Submit outline approval and continue streaming. */ submitOutline: (input: { sections: OutlineSection[] }) => Promise; - /** - * Acknowledge research conflicts and continue to Structure (#953). - * 調査の矛盾を確認し Structure へ進む(#953)。 - */ submitConflictAck: (input?: { note?: string }) => Promise; - /** Cancel the session (DELETE). */ cancel: () => Promise; + /** True when a failed fresh-compose auto-start can be retried from the UI. */ + canRetryStart: boolean; } -/** First interrupt kind on a LangGraph checkpoint output, if any. */ -function interruptKindFromOutput(output: unknown): string | undefined { - if (!output || typeof output !== "object") return undefined; - const interrupts = (output as { __interrupt__?: unknown }).__interrupt__; - if (!Array.isArray(interrupts) || interrupts.length === 0) return undefined; - const entry = interrupts[0]; - const value = - entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; - if (value && typeof value === "object" && "kind" in value) { - const kind = (value as { kind?: unknown }).kind; - return typeof kind === "string" ? kind : undefined; - } - return undefined; -} - -/** - * Returns a unique id for an activity row. Uses crypto.randomUUID when - * available (modern browsers); falls back to a coarse fallback for old - * environments and SSR. - */ -/** - * `session.metadata.composeSeed` を型検証して graph input 用 seed にする。 - * Validate persisted `metadata.composeSeed` before sending `/run` input. - */ -function parseComposeSeedFromMetadata(metadata: Record | null | undefined): - | { - outline: string; - conversationText: string; - userSchema?: string; - conversationId?: string; - } - | undefined { - if (!metadata || typeof metadata !== "object") return undefined; - const raw = metadata.composeSeed; - if (!raw || typeof raw !== "object") return undefined; - const seed = raw as Record; - if (typeof seed.outline !== "string" || typeof seed.conversationText !== "string") { - return undefined; - } - const out: { - outline: string; - conversationText: string; - userSchema?: string; - conversationId?: string; - } = { - outline: seed.outline, - conversationText: seed.conversationText, - }; - if (typeof seed.userSchema === "string" && seed.userSchema.trim()) { - out.userSchema = seed.userSchema; - } - if (typeof seed.conversationId === "string" && seed.conversationId.trim()) { - out.conversationId = seed.conversationId; - } - return out; -} - -function activityId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); - return `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -/** Merge graph run input with the active UI content locale. */ -function withContentLocale(input?: Record): Record { - return { ...(input ?? {}), contentLocale: resolveComposeContentLocale() }; -} - -/** Human-readable phase label for activity log entries. */ -function phaseDisplayLabel(phase: string): string { - const key = `wikiCompose.phaseDisplay.${phase}` as const; - const translated = i18n.t(key); - return translated === key ? phase : translated; -} - -/** - * Map an SSE event into a state update. Returns a partial state to merge. - */ -function reduceEvent( - prev: WikiComposeSessionState, - event: ComposeSseEvent, -): Partial { - switch (event.type) { - case "started": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.runStarted"), - detail: event.graphId, - status: "info", - }), - }; - case "compose_phase": - return { - phase: event.phase, - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.phase", { phase: phaseDisplayLabel(event.phase) }), - detail: event.status, - status: event.status === "entered" ? "started" : "completed", - }), - }; - case "status": - // Server-side `status` events use a colon-namespaced phase string - // (e.g. "brief:await_user"); we don't surface those as the top-level - // phase, only as activity log entries for debug. - return { - activity: appendActivity(prev.activity, { - label: event.phase, - detail: event.message, - status: "info", - }), - }; - case "tool_start": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.toolStarted", { tool: event.tool }), - detail: event.input ? "running" : undefined, - status: "started", - }), - }; - case "tool_end": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.toolDone", { tool: event.tool }), - detail: event.error ?? (event.outputLength ? `${event.outputLength} chars` : "ok"), - status: event.error ? "error" : "completed", - }), - }; - case "research_iteration": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.researchIteration", { - count: event.iteration + 1, - }), - detail: `${event.status} · ${event.queryCount} queries`, - status: "info", - }), - }; - case "research_evaluation": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.sufficiency", { - score: event.score.toFixed(2), - }), - detail: event.rationale, - status: "info", - }), - }; - case "research_batch": - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.researchBatch", { - iteration: event.iteration, - }), - detail: `${event.sourceCount} sources · ${event.exitReason}`, - status: "completed", - }), - }; - case "compose_section": - if (event.status === "started") { - return { - streamingSectionId: event.sectionId, - sectionBuffers: { ...prev.sectionBuffers, [event.sectionId]: "" }, - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.drafting", { heading: event.heading }), - detail: `${event.index} / ${event.total}`, - status: "started", - }), - }; - } - return { - streamingSectionId: - prev.streamingSectionId === event.sectionId ? null : prev.streamingSectionId, - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.drafted", { heading: event.heading }), - detail: `${event.index} / ${event.total}`, - status: "completed", - }), - }; - case "token": { - const id = prev.streamingSectionId; - if (!id) return {}; - const prior = prev.sectionBuffers[id] ?? ""; - return { - sectionBuffers: { ...prev.sectionBuffers, [id]: prior + event.content }, - }; - } - case "interrupt": - return reduceInterrupt(prev, event.payload); - case "done": - return { - isStreaming: false, - status: event.status, - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.runStatus", { status: event.status }), - status: event.status === "completed" ? "completed" : "info", - }), - }; - case "error": - return { - error: event.message, - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.error"), - detail: event.message, - status: "error", - }), - }; - case "usage": - // Usage doesn't change UI state directly, but log it for debug. - return { - activity: appendActivity(prev.activity, { - label: i18n.t("wikiCompose.activity.usage"), - detail: `in=${event.inputTokens} out=${event.outputTokens} cu=${event.costUnits}`, - status: "info", - }), - }; - default: - return {}; - } -} - -function appendActivity( - prev: ComposeActivity[], - next: Omit, -): ComposeActivity[] { - const entry: ComposeActivity = { - id: activityId(), - at: new Date().toISOString(), - ...next, - }; - // Cap the activity log so a long-running session does not grow unbounded. - // 直近 200 件のみ保持する(DOM 描画コスト対策)。 - const merged = [...prev, entry]; - return merged.length > 200 ? merged.slice(merged.length - 200) : merged; -} - -/** - * Extract UI state from a non-streaming `PATCH /resume` response body. - * - * Resume runs the graph via `invoke`, so tokens and interrupts are returned in - * `output` rather than over SSE. The hook must hydrate phase slices from that - * payload; relying on a follow-up `POST /run` would pass fresh `input` to an - * interrupted checkpoint (invalid for LangGraph) and drop `completion` on the - * final outline approve path. - */ -/** - * Merge `GET /compose-sessions/:id` checkpoint projection into hook state (#950). - * `GET` の projection をフック state にマージする(リロード再開用)。 - */ -function hydrateFromProjection( - projection: ComposeSessionUiProjection, -): Partial { - const partial: Partial = {}; - if (projection.phase) partial.phase = projection.phase; - if (projection.briefQuestions?.length) partial.briefQuestions = projection.briefQuestions; - if (projection.pageSnapshot) partial.pageSnapshot = projection.pageSnapshot; - if (projection.latestBatch !== undefined) partial.latestBatch = projection.latestBatch; - if (projection.pendingSources?.length) partial.pendingSources = projection.pendingSources; - if (projection.approvedSources?.length) partial.approvedSources = projection.approvedSources; - if (projection.researchConflictSummary) { - partial.researchConflictSummary = projection.researchConflictSummary; - } - if (projection.outlineProposal?.length) partial.outlineProposal = projection.outlineProposal; - if (projection.completedMarkdown) partial.completedMarkdown = projection.completedMarkdown; - if (projection.draftedSections?.length) { - const draftedSections: Record = {}; - for (const section of projection.draftedSections) { - if (section?.sectionId) draftedSections[section.sectionId] = section; - } - partial.draftedSections = draftedSections; - } - return partial; -} - -/** - * Build hook state context for resume-time interrupt projection. - * resume 時の interrupt 投影用に、チェックポイント上の approvedResearch を引き継ぐ。 - */ -function interruptContextFromCheckpoint(state: Record): WikiComposeSessionState { - const approved = Array.isArray(state.approvedResearch) - ? (state.approvedResearch as ResearchSource[]) - : []; - return { ...INITIAL_STATE, approvedSources: approved }; +function resolveStartPolicy(args: UseWikiComposeSessionArgs): ComposeStartPolicy { + if (args.startPolicy) return args.startPolicy; + if (args.autoStart === false) return "never"; + return "on-mount"; } -function reduceResumeOutput( - output: unknown, - status: ComposeSessionStatus, -): Partial { - if (!output || typeof output !== "object") { - return status === "completed" ? { phase: "completed" } : {}; - } - const state = output as Record; - const partial: Partial = {}; - - const interrupts = state.__interrupt__; - if (Array.isArray(interrupts) && interrupts.length > 0) { - const entry = interrupts[0]; - const value = - entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; - if (value && typeof value === "object" && "kind" in value) { - Object.assign( - partial, - reduceInterrupt(interruptContextFromCheckpoint(state), value as ComposeInterruptPayload), - ); - } - } - - const completion = state.completion; - if (completion && typeof completion === "object") { - const c = completion as { - markdown?: string; - sections?: DraftedSection[]; - }; - if (typeof c.markdown === "string" && c.markdown.length > 0) { - partial.completedMarkdown = c.markdown; - } - if (Array.isArray(c.sections)) { - const draftedSections: Record = {}; - for (const section of c.sections) { - if (!section || typeof section !== "object") continue; - const s = section as DraftedSection; - if (typeof s.sectionId === "string") draftedSections[s.sectionId] = s; - } - partial.draftedSections = draftedSections; - partial.phase = "completed"; - const approvedOutline = state.approvedOutline as { sections?: OutlineSection[] } | undefined; - if (approvedOutline?.sections?.length) { - partial.outlineProposal = approvedOutline.sections; - } else if (c.sections.length > 0) { - partial.outlineProposal = c.sections.map((s) => ({ - id: s.sectionId, - heading: s.heading, - depth: 1, - intent: "", - })); - } - } - } - - if (status === "completed" && partial.phase !== "completed") { - partial.phase = "completed"; - } - - return partial; -} - -function reduceInterrupt( - prev: WikiComposeSessionState, - payload: ComposeInterruptPayload | undefined, -): Partial { - if (!payload) return {}; - switch (payload.kind) { - case "human_review_brief": - return { - briefQuestions: payload.questions, - pageSnapshot: payload.pageSnapshot, - phase: "brief", - }; - case "human_review_research": - return { - latestBatch: payload.batch, - pendingSources: payload.pendingSources, - phase: "research", - }; - case "human_review_outline": - return { - outlineProposal: payload.outline, - approvedSources: payload.approvedSources, - researchConflictSummary: null, - phase: "structure", - }; - case "conflict_resolution": - return { - researchConflictSummary: payload.conflicts, - phase: "conflict", - // Keep approvals from `prev` (SSE) or checkpoint context (resume); do not - // overwrite with an empty array when `reduceResumeOutput` seeds context. - ...(prev.approvedSources.length > 0 ? { approvedSources: prev.approvedSources } : {}), - }; - default: - return {}; - } -} - -/** - * `useWikiComposeSession` — owns SSE wiring and state reduction for one - * compose session. The page component reads the returned state and calls the - * submit functions to advance phases. - */ export function useWikiComposeSession( args: UseWikiComposeSessionArgs, ): UseWikiComposeSessionReturn { @@ -560,24 +88,29 @@ export function useWikiComposeSession( sessionId: initialSessionId, initialInput, composeSeed, - autoStart = true, - backend = "zedi_managed", + backend: backendOverride = "zedi_managed", } = args; - const [state, setState] = useState(INITIAL_STATE); + + const startPolicy = resolveStartPolicy(args); + const loadBackendFromSettings = startPolicy === "when-backend-ready"; + const { backend: settingsBackend, isResolved: isBackendResolved } = useInitialComposeBackend({ + enabled: loadBackendFromSettings, + }); + const backend = loadBackendFromSettings ? settingsBackend : backendOverride; + + const [state, setState] = useState(INITIAL_WIKI_COMPOSE_SESSION_STATE); const sessionRef = useRef(null); const abortRef = useRef(null); + const autoStartRequestedRef = useRef(false); - /** Merge a partial update into state. */ const update = useCallback((partial: Partial) => { setState((prev) => ({ ...prev, ...partial })); }, []); - /** Consume an SSE event by reducing it into state. */ const onEvent = useCallback((event: ComposeSseEvent) => { - setState((prev) => ({ ...prev, ...reduceEvent(prev, event) })); + setState((prev) => ({ ...prev, ...reduceComposeSseEvent(prev, event) })); }, []); - /** Stream a `runSession` call and update state from events. */ const streamRun = useCallback( async (session: ComposeSession, body?: Record) => { abortRef.current?.abort(); @@ -593,11 +126,7 @@ export function useWikiComposeSession( signal: controller.signal, }); } catch (err) { - if ((err as { name?: string }).name === "AbortError") { - // Caller aborted; do not surface as an error. - // ユーザー操作による abort は error として扱わない。 - return; - } + if ((err as { name?: string }).name === "AbortError") return; const message = err instanceof Error ? err.message : String(err); update({ error: message, isStreaming: false }); } finally { @@ -608,7 +137,6 @@ export function useWikiComposeSession( [pageId, onEvent, update], ); - /** Create or resume the session, then begin streaming. */ const start = useCallback(async () => { try { const loaded = initialSessionId ? await getSession(pageId, initialSessionId) : null; @@ -629,7 +157,7 @@ export function useWikiComposeSession( : undefined, })); const projectionHydration = loaded?.projection - ? hydrateFromProjection(loaded.projection) + ? hydrateComposeFromProjection(loaded.projection) : {}; sessionRef.current = session; @@ -644,9 +172,6 @@ export function useWikiComposeSession( } : undefined); - // Only fresh / retriable rows may call `POST /run` with graph input. - // Interrupted checkpoints require `Command({ resume })`; replaying input - // would restart or error, and resume payloads are not stored on the row. if (session.status === "pending" || session.status === "failed") { await streamRun(session, withContentLocale(runInput)); } @@ -661,7 +186,7 @@ export function useWikiComposeSession( const session = sessionRef.current; if (!session) throw new Error("Session not initialised"); const result = await resumeSession({ pageId, sessionId: session.id, resume: input }); - const fromResume = reduceResumeOutput(result.output, result.status); + const fromResume = reduceComposeResumeOutput(result.output, result.status); update({ status: result.status, ...fromResume }); }, [pageId, update], @@ -671,18 +196,10 @@ export function useWikiComposeSession( async (input) => { const session = sessionRef.current; if (!session) throw new Error("Session not initialised"); - // Mirror the approved sources into state immediately so the UI can show - // the user's choice without waiting for the server's projection. - // resume 直後に approvedSources を仮反映してフロントの追随を早める。 const approved = state.pendingSources.filter((s) => input.approvedSourceIds.includes(s.id)); update({ approvedSources: approved }); const result = await resumeSession({ pageId, sessionId: session.id, resume: input }); - if (interruptKindFromOutput(result.output) === "conflict_resolution") { - const fromResume = reduceResumeOutput(result.output, result.status); - update({ status: result.status, ...fromResume }); - return; - } - const fromResume = reduceResumeOutput(result.output, result.status); + const fromResume = reduceComposeResumeOutput(result.output, result.status); update({ status: result.status, ...fromResume }); }, [pageId, state.pendingSources, update], @@ -697,7 +214,7 @@ export function useWikiComposeSession( sessionId: session.id, resume: { acknowledged: true as const, ...(input?.note ? { note: input.note } : {}) }, }); - const fromResume = reduceResumeOutput(result.output, result.status); + const fromResume = reduceComposeResumeOutput(result.output, result.status); update({ status: result.status, researchConflictSummary: null, @@ -712,7 +229,7 @@ export function useWikiComposeSession( const session = sessionRef.current; if (!session) throw new Error("Session not initialised"); const result = await resumeSession({ pageId, sessionId: session.id, resume: input }); - const fromResume = reduceResumeOutput(result.output, result.status); + const fromResume = reduceComposeResumeOutput(result.output, result.status); update({ status: result.status, ...fromResume }); }, [pageId, update], @@ -726,11 +243,25 @@ export function useWikiComposeSession( update({ status: "cancelled" }); }, [pageId, update]); - // Auto-start on mount when requested. The dependency list is intentionally - // narrow so we don't double-start on prop changes. - // 自動開始は mount 時のみ。引数変更で再起動しないよう依存を意図的に絞る。 + const awaitingFreshStart = + !initialSessionId && state.status === "idle" && !state.session && !state.isStreaming; + + const canRetryStart = + startPolicy === "when-backend-ready" && + !initialSessionId && + Boolean(state.error) && + !state.session && + !state.isStreaming && + (state.status === "idle" || state.status === "failed"); + useEffect(() => { - if (!autoStart) return; + if (startPolicy === "never") return; + if (startPolicy === "when-backend-ready") { + if (!isBackendResolved || !awaitingFreshStart) return; + } + if (autoStartRequestedRef.current) return; + autoStartRequestedRef.current = true; + let cancelled = false; void start().catch((err) => { if (cancelled) return; @@ -741,12 +272,14 @@ export function useWikiComposeSession( cancelled = true; abortRef.current?.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [startPolicy, isBackendResolved, awaitingFreshStart, start, update]); + + useEffect(() => { + if (startPolicy !== "when-backend-ready") return; + if (initialSessionId || state.session || !state.error) return; + autoStartRequestedRef.current = false; + }, [startPolicy, initialSessionId, state.session, state.error]); - // Derive completion markdown from drafted sections when status flips. - // 完了時に Markdown を組み立てるロジックは backend `completed` ノードと別系統 - // でも UI 側で再構築できるよう、フックでも軽量に持つ。 const completedMarkdown = useMemo(() => { if (state.status !== "completed") return state.completedMarkdown; if (state.completedMarkdown) return state.completedMarkdown; @@ -765,7 +298,7 @@ export function useWikiComposeSession( submitResearchApproval, submitConflictAck, submitOutline, - submitConflictAck, cancel, + canRetryStart, }; } diff --git a/src/lib/wikiCompose/resolveComposeBackend.test.ts b/src/lib/wikiCompose/resolveComposeBackend.test.ts index 51d24499..e0e59e7a 100644 --- a/src/lib/wikiCompose/resolveComposeBackend.test.ts +++ b/src/lib/wikiCompose/resolveComposeBackend.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest"; import type { AISettings } from "@/types/ai"; import { + coerceWikiComposeBackend, isComposeBackendAvailable, + isWikiComposeAllowedBackend, resolveComposeBackendFromAiSettings, resolvePreferredComposeBackend, } from "./resolveComposeBackend"; @@ -35,6 +37,15 @@ const credentialsOpenAi: UserAiCredentialsStatus = { ], }; +const credentialsGoogle: UserAiCredentialsStatus = { + storageEnabled: true, + providers: [ + { provider: "anthropic", configured: false }, + { provider: "openai", configured: false }, + { provider: "google", configured: true }, + ], +}; + describe("resolvePreferredComposeBackend", () => { it("maps api_server mode to zedi_managed", () => { expect(resolvePreferredComposeBackend(baseSettings({ provider: "openai" }))).toBe( @@ -76,6 +87,28 @@ describe("isComposeBackendAvailable", () => { }); }); +describe("isWikiComposeAllowedBackend", () => { + it("allows zedi_managed and user_google only", () => { + expect(isWikiComposeAllowedBackend("zedi_managed")).toBe(true); + expect(isWikiComposeAllowedBackend("user_google")).toBe(true); + expect(isWikiComposeAllowedBackend("user_openai")).toBe(false); + expect(isWikiComposeAllowedBackend("user_anthropic")).toBe(false); + }); +}); + +describe("coerceWikiComposeBackend", () => { + it("falls back to user_google when preferred non-Google BYOK but Google credential exists", () => { + expect(coerceWikiComposeBackend("user_openai", credentialsGoogle)).toBe("user_google"); + expect(coerceWikiComposeBackend("user_anthropic", credentialsGoogle)).toBe("user_google"); + }); + + it("falls back to zedi_managed when only non-Google BYOK credentials exist", () => { + expect(coerceWikiComposeBackend("user_openai", credentialsOpenAi)).toBe("zedi_managed"); + expect(coerceWikiComposeBackend("user_anthropic", credentialsOpenAi)).toBe("zedi_managed"); + expect(coerceWikiComposeBackend("user_openai", credentialsNone)).toBe("zedi_managed"); + }); +}); + describe("resolveComposeBackendFromAiSettings", () => { it("falls back to zedi_managed when preferred BYOK is unavailable", () => { expect( @@ -86,12 +119,21 @@ describe("resolveComposeBackendFromAiSettings", () => { ).toBe("zedi_managed"); }); - it("keeps user_* when credential exists", () => { + it("maps non-Google BYOK to user_google when Google credential exists (#990)", () => { expect( resolveComposeBackendFromAiSettings( baseSettings({ apiMode: "user_api_key", provider: "openai", isConfigured: true }), - credentialsOpenAi, + credentialsGoogle, + ), + ).toBe("user_google"); + }); + + it("keeps user_google when preferred and credential exists", () => { + expect( + resolveComposeBackendFromAiSettings( + baseSettings({ apiMode: "user_api_key", provider: "google", isConfigured: true }), + credentialsGoogle, ), - ).toBe("user_openai"); + ).toBe("user_google"); }); }); diff --git a/src/lib/wikiCompose/resolveComposeBackend.ts b/src/lib/wikiCompose/resolveComposeBackend.ts index c7b8bfc1..d50d8b14 100644 --- a/src/lib/wikiCompose/resolveComposeBackend.ts +++ b/src/lib/wikiCompose/resolveComposeBackend.ts @@ -45,16 +45,39 @@ export function isComposeBackendAvailable( } /** - * Resolve compose backend from AI settings, falling back when BYOK is unavailable. - * AI 設定から backend を決め、BYOK が使えない場合は zedi_managed にフォールバックする。 + * Backends accepted by Wiki Compose while the graph pins LLM calls to a Google model (#990). + * Wiki Compose が Google 固定モデル運用中に受け付ける backend。 */ -export function resolveComposeBackendFromAiSettings( - settings: AISettings, +export function isWikiComposeAllowedBackend(backend: ComposeExecutionBackend): boolean { + return backend === "zedi_managed" || backend === "user_google"; +} + +/** + * Map a settings-derived backend to one the Wiki Compose API accepts. + * 設定由来の backend を Wiki Compose API が受け付ける値に矯正する。 + */ +export function coerceWikiComposeBackend( + preferred: ComposeExecutionBackend, credentials: UserAiCredentialsStatus, ): ComposeExecutionBackend { - const preferred = resolvePreferredComposeBackend(settings); - if (isComposeBackendAvailable(preferred, credentials)) { + if (isWikiComposeAllowedBackend(preferred) && isComposeBackendAvailable(preferred, credentials)) { return preferred; } + if (isComposeBackendAvailable("user_google", credentials)) { + return "user_google"; + } return "zedi_managed"; } + +/** + * Resolve compose backend from AI settings, falling back when BYOK is unavailable + * or incompatible with the fixed Google Wiki Compose model (#990). + * AI 設定から backend を決め、BYOK 不可・非 Google BYOK は zedi_managed / user_google に落とす。 + */ +export function resolveComposeBackendFromAiSettings( + settings: AISettings, + credentials: UserAiCredentialsStatus, +): ComposeExecutionBackend { + const preferred = resolvePreferredComposeBackend(settings); + return coerceWikiComposeBackend(preferred, credentials); +} diff --git a/src/lib/wikiCompose/wikiComposeSessionReducer.ts b/src/lib/wikiCompose/wikiComposeSessionReducer.ts new file mode 100644 index 00000000..57e14a76 --- /dev/null +++ b/src/lib/wikiCompose/wikiComposeSessionReducer.ts @@ -0,0 +1,424 @@ +/** + * Pure state reduction for {@link useWikiComposeSession} (#950). + * SSE / resume / projection → UI state。React 非依存。 + */ +import i18n from "@/i18n"; +import { resolveComposeContentLocale } from "@/lib/wikiCompose/resolveComposeContentLocale"; +import type { + BriefQuestion, + ComposeInterruptPayload, + ComposeSession, + ComposeSessionStatus, + ComposeSessionUiProjection, + ComposeSseEvent, + DraftedSection, + OutlineSection, + PageSnapshot, + ResearchBatch, + ResearchConflictSummary, + ResearchSource, +} from "@/lib/wikiCompose/types"; + +/** UI phase for Wiki Compose session progression. */ +export type ComposePhase = "brief" | "research" | "conflict" | "structure" | "draft" | "completed"; + +/** Activity log entry surfaced in the right pane's ActivitySection. */ +export interface ComposeActivity { + id: string; + at: string; + label: string; + detail?: string; + status?: "started" | "completed" | "info" | "error"; +} + +/** Aggregate state surfaced to the UI. */ +export interface WikiComposeSessionState { + session: ComposeSession | null; + status: ComposeSessionStatus | "idle"; + phase: ComposePhase; + briefQuestions: BriefQuestion[]; + pageSnapshot: PageSnapshot | null; + latestBatch: ResearchBatch | null; + pendingSources: ResearchSource[]; + approvedSources: ResearchSource[]; + researchConflictSummary: ResearchConflictSummary | null; + outlineProposal: OutlineSection[]; + draftedSections: Record; + streamingSectionId: string | null; + sectionBuffers: Record; + activity: ComposeActivity[]; + completedMarkdown: string | null; + error: string | null; + isStreaming: boolean; +} + +export const INITIAL_WIKI_COMPOSE_SESSION_STATE: WikiComposeSessionState = { + session: null, + status: "idle", + phase: "brief", + briefQuestions: [], + pageSnapshot: null, + latestBatch: null, + pendingSources: [], + approvedSources: [], + researchConflictSummary: null, + outlineProposal: [], + draftedSections: {}, + streamingSectionId: null, + sectionBuffers: {}, + activity: [], + completedMarkdown: null, + error: null, + isStreaming: false, +}; + +/** First interrupt kind on a LangGraph checkpoint output, if any. */ +export function interruptKindFromOutput(output: unknown): string | undefined { + if (!output || typeof output !== "object") return undefined; + const interrupts = (output as { __interrupt__?: unknown }).__interrupt__; + if (!Array.isArray(interrupts) || interrupts.length === 0) return undefined; + const entry = interrupts[0]; + const value = + entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; + if (value && typeof value === "object" && "kind" in value) { + const kind = (value as { kind?: unknown }).kind; + return typeof kind === "string" ? kind : undefined; + } + return undefined; +} + +/** + * Validate persisted `metadata.composeSeed` before sending `/run` input. + */ +export function parseComposeSeedFromMetadata(metadata: Record | null | undefined): + | { + outline: string; + conversationText: string; + userSchema?: string; + conversationId?: string; + } + | undefined { + if (!metadata || typeof metadata !== "object") return undefined; + const raw = metadata.composeSeed; + if (!raw || typeof raw !== "object") return undefined; + const seed = raw as Record; + if (typeof seed.outline !== "string" || typeof seed.conversationText !== "string") { + return undefined; + } + const out: { + outline: string; + conversationText: string; + userSchema?: string; + conversationId?: string; + } = { + outline: seed.outline, + conversationText: seed.conversationText, + }; + if (typeof seed.userSchema === "string" && seed.userSchema.trim()) { + out.userSchema = seed.userSchema; + } + if (typeof seed.conversationId === "string" && seed.conversationId.trim()) { + out.conversationId = seed.conversationId; + } + return out; +} + +/** Merge graph run input with the active UI content locale. */ +export function withContentLocale(input?: Record): Record { + return { ...(input ?? {}), contentLocale: resolveComposeContentLocale() }; +} + +function activityId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function phaseDisplayLabel(phase: string): string { + const key = `wikiCompose.phaseDisplay.${phase}` as const; + const translated = i18n.t(key); + return translated === key ? phase : translated; +} + +function appendActivity( + prev: ComposeActivity[], + next: Omit, +): ComposeActivity[] { + const entry: ComposeActivity = { + id: activityId(), + at: new Date().toISOString(), + ...next, + }; + const merged = [...prev, entry]; + return merged.length > 200 ? merged.slice(merged.length - 200) : merged; +} + +function reduceInterrupt( + prev: WikiComposeSessionState, + payload: ComposeInterruptPayload | undefined, +): Partial { + if (!payload) return {}; + switch (payload.kind) { + case "human_review_brief": + return { + briefQuestions: payload.questions, + pageSnapshot: payload.pageSnapshot, + phase: "brief", + }; + case "human_review_research": + return { + latestBatch: payload.batch, + pendingSources: payload.pendingSources, + phase: "research", + }; + case "human_review_outline": + return { + outlineProposal: payload.outline, + approvedSources: payload.approvedSources, + researchConflictSummary: null, + phase: "structure", + }; + case "conflict_resolution": + return { + researchConflictSummary: payload.conflicts, + phase: "conflict", + ...(prev.approvedSources.length > 0 ? { approvedSources: prev.approvedSources } : {}), + }; + default: + return {}; + } +} + +/** Map an SSE event into a state update. */ +export function reduceComposeSseEvent( + prev: WikiComposeSessionState, + event: ComposeSseEvent, +): Partial { + switch (event.type) { + case "started": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.runStarted"), + detail: event.graphId, + status: "info", + }), + }; + case "compose_phase": + return { + phase: event.phase, + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.phase", { phase: phaseDisplayLabel(event.phase) }), + detail: event.status, + status: event.status === "entered" ? "started" : "completed", + }), + }; + case "status": + return { + activity: appendActivity(prev.activity, { + label: event.phase, + detail: event.message, + status: "info", + }), + }; + case "tool_start": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.toolStarted", { tool: event.tool }), + detail: event.input ? "running" : undefined, + status: "started", + }), + }; + case "tool_end": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.toolDone", { tool: event.tool }), + detail: event.error ?? (event.outputLength ? `${event.outputLength} chars` : "ok"), + status: event.error ? "error" : "completed", + }), + }; + case "research_iteration": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.researchIteration", { + count: event.iteration + 1, + }), + detail: `${event.status} · ${event.queryCount} queries`, + status: "info", + }), + }; + case "research_evaluation": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.sufficiency", { + score: event.score.toFixed(2), + }), + detail: event.rationale, + status: "info", + }), + }; + case "research_batch": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.researchBatch", { + iteration: event.iteration, + }), + detail: `${event.sourceCount} sources · ${event.exitReason}`, + status: "completed", + }), + }; + case "compose_section": + if (event.status === "started") { + return { + streamingSectionId: event.sectionId, + sectionBuffers: { ...prev.sectionBuffers, [event.sectionId]: "" }, + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.drafting", { heading: event.heading }), + detail: `${event.index} / ${event.total}`, + status: "started", + }), + }; + } + return { + streamingSectionId: + prev.streamingSectionId === event.sectionId ? null : prev.streamingSectionId, + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.drafted", { heading: event.heading }), + detail: `${event.index} / ${event.total}`, + status: "completed", + }), + }; + case "token": { + const id = prev.streamingSectionId; + if (!id) return {}; + const prior = prev.sectionBuffers[id] ?? ""; + return { + sectionBuffers: { ...prev.sectionBuffers, [id]: prior + event.content }, + }; + } + case "interrupt": + return reduceInterrupt(prev, event.payload); + case "done": + return { + isStreaming: false, + status: event.status, + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.runStatus", { status: event.status }), + status: event.status === "completed" ? "completed" : "info", + }), + }; + case "error": + return { + error: event.message, + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.error"), + detail: event.message, + status: "error", + }), + }; + case "usage": + return { + activity: appendActivity(prev.activity, { + label: i18n.t("wikiCompose.activity.usage"), + detail: `in=${event.inputTokens} out=${event.outputTokens} cu=${event.costUnits}`, + status: "info", + }), + }; + default: + return {}; + } +} + +/** Merge `GET` checkpoint projection into hook state. */ +export function hydrateComposeFromProjection( + projection: ComposeSessionUiProjection, +): Partial { + const partial: Partial = {}; + if (projection.phase) partial.phase = projection.phase; + if (projection.briefQuestions?.length) partial.briefQuestions = projection.briefQuestions; + if (projection.pageSnapshot) partial.pageSnapshot = projection.pageSnapshot; + if (projection.latestBatch !== undefined) partial.latestBatch = projection.latestBatch; + if (projection.pendingSources?.length) partial.pendingSources = projection.pendingSources; + if (projection.approvedSources?.length) partial.approvedSources = projection.approvedSources; + if (projection.researchConflictSummary) { + partial.researchConflictSummary = projection.researchConflictSummary; + } + if (projection.outlineProposal?.length) partial.outlineProposal = projection.outlineProposal; + if (projection.completedMarkdown) partial.completedMarkdown = projection.completedMarkdown; + if (projection.draftedSections?.length) { + const draftedSections: Record = {}; + for (const section of projection.draftedSections) { + if (section?.sectionId) draftedSections[section.sectionId] = section; + } + partial.draftedSections = draftedSections; + } + return partial; +} + +function interruptContextFromCheckpoint(state: Record): WikiComposeSessionState { + const approved = Array.isArray(state.approvedResearch) + ? (state.approvedResearch as ResearchSource[]) + : []; + return { ...INITIAL_WIKI_COMPOSE_SESSION_STATE, approvedSources: approved }; +} + +/** Extract UI state from a non-streaming `PATCH /resume` response body. */ +export function reduceComposeResumeOutput( + output: unknown, + status: ComposeSessionStatus, +): Partial { + if (!output || typeof output !== "object") { + return status === "completed" ? { phase: "completed" } : {}; + } + const state = output as Record; + const partial: Partial = {}; + + const interrupts = state.__interrupt__; + if (Array.isArray(interrupts) && interrupts.length > 0) { + const entry = interrupts[0]; + const value = + entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined; + if (value && typeof value === "object" && "kind" in value) { + Object.assign( + partial, + reduceInterrupt(interruptContextFromCheckpoint(state), value as ComposeInterruptPayload), + ); + } + } + + const completion = state.completion; + if (completion && typeof completion === "object") { + const c = completion as { + markdown?: string; + sections?: DraftedSection[]; + }; + if (typeof c.markdown === "string" && c.markdown.length > 0) { + partial.completedMarkdown = c.markdown; + } + if (Array.isArray(c.sections)) { + const draftedSections: Record = {}; + for (const section of c.sections) { + if (!section || typeof section !== "object") continue; + const s = section as DraftedSection; + if (typeof s.sectionId === "string") draftedSections[s.sectionId] = s; + } + partial.draftedSections = draftedSections; + partial.phase = "completed"; + const approvedOutline = state.approvedOutline as { sections?: OutlineSection[] } | undefined; + if (approvedOutline?.sections?.length) { + partial.outlineProposal = approvedOutline.sections; + } else if (c.sections.length > 0) { + partial.outlineProposal = c.sections.map((s) => ({ + id: s.sectionId, + heading: s.heading, + depth: 1, + intent: "", + })); + } + } + } + + if (status === "completed" && partial.phase !== "completed") { + partial.phase = "completed"; + } + + return partial; +} diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index 8bb0a889..8396866a 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -1,16 +1,7 @@ /** * `WikiComposePage` — Wiki Compose split-screen UI (#950). - * - * `/notes/:noteId/:pageId/compose` (および `compose/:sessionId`) のルート要素。 - * 左ペイン = `EditorPane` (タイトル + 進捗中の本文プレビュー)、右ペイン = - * `ComposePanel` (PhaseStepper + Dialogue + Research + Activity)。 - * モバイルでは縦分割、デスクトップでは横分割で表示する。Compose 完了 / 中断 - * 時はノートページに戻れる。 - * - * Compose UI shell. The page reads the `useWikiComposeSession` hook for state - * and routes user submissions back through the hook's mutator methods. */ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, X } from "lucide-react"; @@ -29,7 +20,6 @@ import { COMPOSE_SEED_STATE_KEY, type ComposeNavigationSeed } from "@/lib/wikiCo import type { DraftedSection } from "@/lib/wikiCompose/types"; import { EditorPane } from "@/components/wikiCompose/EditorPane"; import { ComposePanel } from "@/components/wikiCompose/ComposePanel"; -import { useInitialComposeBackend } from "@/hooks/useInitialComposeBackend"; /** Map drafted section list to a quick lookup. */ function indexById(items: DraftedSection[]): Record { @@ -50,8 +40,6 @@ const WikiComposePage: React.FC = () => { const pageId = params.pageId ?? ""; const sessionId = params.sessionId ?? null; - // チャット seed は mount 時に 1 回だけ保持。`location.state` を消しても hook 側に残す。 - // Capture chat seed once on mount; survives clearing `location.state` for the hook. const [composeSeed] = useState((): ComposeNavigationSeed | undefined => { const raw = (location.state as Record | null)?.[COMPOSE_SEED_STATE_KEY]; if (!raw || typeof raw !== "object") return undefined; @@ -60,11 +48,6 @@ const WikiComposePage: React.FC = () => { return s; }); - const { backend: composeBackend, isResolved: isComposeBackendResolved } = - useInitialComposeBackend({ - enabled: !sessionId, - }); - const initialInput = useMemo( () => composeSeed @@ -83,43 +66,11 @@ const WikiComposePage: React.FC = () => { const session = useWikiComposeSession({ pageId, sessionId, - // Resume existing session on mount; fresh compose starts after backend resolves. - autoStart: Boolean(sessionId && pageId), + startPolicy: sessionId ? "on-mount" : "when-backend-ready", composeSeed, initialInput, - backend: composeBackend, }); - const awaitingComposeStart = - !sessionId && session.status === "idle" && !session.session && !session.isStreaming; - const autoStartRequestedRef = useRef(false); - - const startComposeSession = session.start; - - // Fresh compose: start automatically once AI settings yield a backend (#951). - useEffect(() => { - if (sessionId || !isComposeBackendResolved || !awaitingComposeStart) return; - if (autoStartRequestedRef.current) return; - autoStartRequestedRef.current = true; - void startComposeSession(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot auto-start when backend resolves - }, [sessionId, isComposeBackendResolved, awaitingComposeStart]); - - // Allow manual retry after a failed auto-start (ref would otherwise block re-entry). - useEffect(() => { - if (sessionId || session.session || !session.error) return; - autoStartRequestedRef.current = false; - }, [sessionId, session.session, session.error]); - - const canRetryComposeStart = - !sessionId && - Boolean(session.error) && - !session.session && - !session.isStreaming && - (session.status === "idle" || session.status === "failed"); - - // Clear history seed only after the session row left `pending` (first run claimed). - // `pending` のまま state を消すと失敗時リロードで chatSeed が届かなくなる (#950)。 useEffect(() => { if (!composeSeed || !location.state) return; if (session.status === "idle" || session.status === "pending") return; @@ -137,7 +88,6 @@ const WikiComposePage: React.FC = () => { session.status, ]); - // Persist the session id in the URL so refresh re-opens the same row. useEffect(() => { const id = session.session?.id; if (!id || sessionId || !noteId || !pageId) return; @@ -149,14 +99,6 @@ const WikiComposePage: React.FC = () => { [session.draftedSections], ); - // The displayed outline switches sources as the user progresses through - // phases: proposal during structure → final approved outline once approved. - // (For the editor pane preview, both are acceptable since they share `id`.) - const outlineForPreview = - session.phase === "completed" || session.phase === "draft" - ? session.outlineProposal - : session.outlineProposal; - const handleBack = () => { if (noteId && pageId) { navigate(`/notes/${noteId}/${pageId}`); @@ -236,7 +178,7 @@ const WikiComposePage: React.FC = () => { const left = ( { onSubmitResearchApproval={session.submitResearchApproval} onSubmitConflictAck={session.submitConflictAck} onSubmitOutline={session.submitOutline} - onSubmitConflictAck={session.submitConflictAck} /> ); @@ -270,14 +211,14 @@ const WikiComposePage: React.FC = () => { {session.error ? (
{session.error} - {canRetryComposeStart ? ( + {session.canRetryStart ? ( From 2fd6169c32fffdccc980c06535104a7dbd5bdccd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 06:11:17 +0000 Subject: [PATCH 2/2] fix(wiki-compose): stop auto-start effect from aborting SSE mid-run When createSession commits, awaitingFreshStart flips and the auto-start useEffect cleanup called abort() on the in-flight POST /run stream. Fresh compose sessions could stall at pending with no SSE progress. --- src/hooks/useWikiComposeSession.test.ts | 54 ++++++++++++++++++++++++- src/hooks/useWikiComposeSession.ts | 14 ++++++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/hooks/useWikiComposeSession.test.ts b/src/hooks/useWikiComposeSession.test.ts index f31f6699..6852f7c3 100644 --- a/src/hooks/useWikiComposeSession.test.ts +++ b/src/hooks/useWikiComposeSession.test.ts @@ -31,10 +31,15 @@ vi.mock("@/lib/wikiCompose/resolveComposeContentLocale", () => ({ resolveComposeContentLocale: () => "ja" as const, })); +const backendMock = vi.hoisted(() => ({ + backend: "zedi_managed" as const, + isResolved: true, +})); + vi.mock("@/hooks/useInitialComposeBackend", () => ({ useInitialComposeBackend: () => ({ - backend: "zedi_managed" as const, - isResolved: true, + backend: backendMock.backend, + isResolved: backendMock.isResolved, }), })); @@ -68,6 +73,7 @@ function arrangeRun(events: ComposeSseEvent[]): void { describe("useWikiComposeSession", () => { beforeEach(() => { + backendMock.isResolved = true; mocks.createSession.mockReset(); mocks.getSession.mockReset(); mocks.runSession.mockReset(); @@ -364,6 +370,50 @@ describe("useWikiComposeSession", () => { ); }); + it("does not abort POST /run when session is created before the stream opens", async () => { + let releaseCreate!: () => void; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + mocks.createSession.mockImplementation(async () => { + await createGate; + return SESSION; + }); + + let abortedBeforeEvents = false; + mocks.runSession.mockImplementation(async ({ signal, onEvent }) => { + // Yield so React can run auto-start effect cleanup after session state commits. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (signal?.aborted) { + abortedBeforeEvents = true; + return; + } + await onEvent({ type: "done", status: "completed" }); + }); + + backendMock.isResolved = false; + const { result, rerender } = renderHook(() => + useWikiComposeSession({ + pageId: "page-1", + sessionId: null, + startPolicy: "when-backend-ready", + }), + ); + + await act(async () => { + backendMock.isResolved = true; + rerender(); + releaseCreate(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => expect(result.current.session).not.toBeNull()); + await waitFor(() => expect(mocks.runSession).toHaveBeenCalled()); + await waitFor(() => expect(result.current.status).toBe("completed")); + expect(abortedBeforeEvents).toBe(false); + }); + it("retries a failed session with chatSeed from row metadata when initialInput is absent", async () => { mocks.getSession.mockResolvedValue({ session: { diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index 68fd62ef..69e5c834 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -181,6 +181,9 @@ export function useWikiComposeSession( } }, [pageId, initialSessionId, initialInput, composeSeed, backend, streamRun, update]); + const startRef = useRef(start); + startRef.current = start; + const submitBrief = useCallback( async (input) => { const session = sessionRef.current; @@ -254,6 +257,8 @@ export function useWikiComposeSession( !state.isStreaming && (state.status === "idle" || state.status === "failed"); + // Auto-start once when policy allows. Do not abort SSE in this effect's cleanup + // when `awaitingFreshStart` flips after createSession — that races with streamRun. useEffect(() => { if (startPolicy === "never") return; if (startPolicy === "when-backend-ready") { @@ -263,16 +268,21 @@ export function useWikiComposeSession( autoStartRequestedRef.current = true; let cancelled = false; - void start().catch((err) => { + void startRef.current().catch((err) => { if (cancelled) return; const message = err instanceof Error ? err.message : String(err); update({ error: message }); }); return () => { cancelled = true; + }; + }, [startPolicy, isBackendResolved, awaitingFreshStart, update]); + + useEffect(() => { + return () => { abortRef.current?.abort(); }; - }, [startPolicy, isBackendResolved, awaitingFreshStart, start, update]); + }, []); useEffect(() => { if (startPolicy !== "when-backend-ready") return;