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
106 changes: 106 additions & 0 deletions src/hooks/useWikiComposeSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,112 @@ describe("useWikiComposeSession", () => {
expect(result.current.streamingSectionId).toBeNull();
});

it("submitOutline hydrates completion from PATCH resume output without POST /run", async () => {
arrangeRun([
{ type: "started", sessionId: SESSION.id, graphId: SESSION.graphId },
{ type: "done", status: "interrupted" },
]);
mocks.resumeSession.mockResolvedValue({
status: "completed",
output: {
completion: {
markdown: "## Overview\n\nBody one\n\n## Details\n\nBody two\n",
sections: [
{
sectionId: "sec-1",
heading: "Overview",
body: "Body one",
citedSourceIds: [],
completedAt: "2026-05-24T00:00:00Z",
},
{
sectionId: "sec-2",
heading: "Details",
body: "Body two",
citedSourceIds: [],
completedAt: "2026-05-24T00:00:01Z",
},
],
citedSources: [],
completedAt: "2026-05-24T00:00:02Z",
},
approvedOutline: {
sections: [
{ id: "sec-1", heading: "Overview", depth: 1, intent: "intro" },
{ id: "sec-2", heading: "Details", depth: 1, intent: "deep" },
],
},
},
});

const { result } = renderHook(() =>
useWikiComposeSession({ pageId: "page-1", sessionId: null }),
);
await waitFor(() => expect(result.current.session).not.toBeNull());

await act(async () => {
await result.current.submitOutline({
sections: [
{ id: "sec-1", heading: "Overview", depth: 1, intent: "intro" },
{ id: "sec-2", heading: "Details", depth: 1, intent: "deep" },
],
});
});

expect(mocks.runSession).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.status).toBe("completed"));
expect(result.current.completedMarkdown).toContain("Body one");
expect(result.current.draftedSections["sec-1"]?.body).toBe("Body one");
expect(result.current.phase).toBe("completed");
});

it("submitBrief applies research interrupt from PATCH output without POST /run", async () => {
arrangeRun([
{ type: "started", sessionId: SESSION.id, graphId: SESSION.graphId },
{ type: "done", status: "interrupted" },
]);
mocks.resumeSession.mockResolvedValue({
status: "interrupted",
output: {
__interrupt__: [
{
value: {
kind: "human_review_research",
batch: {
id: "batch-1",
iteration: 0,
sources: [],
createdAt: "2026-05-24T00:00:00Z",
},
pendingSources: [
{
id: "src-1",
kind: "web",
title: "Example",
url: "https://example.com",
},
],
},
},
],
},
});

const { result } = renderHook(() =>
useWikiComposeSession({ pageId: "page-1", sessionId: null }),
);
await waitFor(() => expect(result.current.session).not.toBeNull());

await act(async () => {
await result.current.submitBrief({ answers: [], appendToExisting: false });
});

expect(mocks.runSession).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.pendingSources).toHaveLength(1));
expect(result.current.pendingSources[0]?.id).toBe("src-1");
expect(result.current.phase).toBe("research");
});

it("submitBrief calls resumeSession with the answer payload and re-streams", async () => {
// Initial run: halt at Brief.
arrangeRun([
Expand Down
109 changes: 98 additions & 11 deletions src/hooks/useWikiComposeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,74 @@ function appendActivity(
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.
*/
function reduceResumeOutput(
output: unknown,
status: ComposeSessionStatus,
): Partial<WikiComposeSessionState> {
if (!output || typeof output !== "object") {
return status === "completed" ? { phase: "completed" } : {};
}
const state = output as Record<string, unknown>;
const partial: Partial<WikiComposeSessionState> = {};

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(INITIAL_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<string, DraftedSection> = {};
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,
Expand Down Expand Up @@ -388,7 +456,12 @@ export function useWikiComposeSession(
: await createSession({ pageId });
sessionRef.current = session;
update({ session, status: session.status, error: null });
await streamRun(session, initialInput);
// 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, initialInput);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
update({ error: message });
Expand All @@ -399,13 +472,15 @@ export function useWikiComposeSession(
async (input) => {
const session = sessionRef.current;
if (!session) throw new Error("Session not initialised");
// Resume returns the new status; if interrupted, we re-open the stream
// so the next phase's events flow to the UI.
// resume が返した時点で次の interrupt まで進んでいる。後続イベントは
// 再度 runSession (= SSE 接続) で取りに行く必要がある。
const result = await resumeSession({ pageId, sessionId: session.id, resume: input });
update({ status: result.status });
if (result.status === "interrupted" || result.status === "running") {
const fromResume = reduceResumeOutput(result.output, result.status);
update({ status: result.status, ...fromResume });
const needsStream =
(result.status === "interrupted" || result.status === "running") &&
!fromResume.briefQuestions?.length &&
!fromResume.pendingSources?.length &&
!fromResume.outlineProposal?.length;
if (needsStream) {
await streamRun(session);
}
},
Expand All @@ -422,8 +497,14 @@ export function useWikiComposeSession(
const approved = state.pendingSources.filter((s) => input.approvedSourceIds.includes(s.id));
update({ approvedSources: approved });
const result = await resumeSession({ pageId, sessionId: session.id, resume: input });
update({ status: result.status });
if (result.status === "interrupted" || result.status === "running") {
const fromResume = reduceResumeOutput(result.output, result.status);
update({ status: result.status, ...fromResume });
const needsStream =
(result.status === "interrupted" || result.status === "running") &&
!fromResume.briefQuestions?.length &&
!fromResume.pendingSources?.length &&
!fromResume.outlineProposal?.length;
if (needsStream) {
await streamRun(session);
}
},
Expand All @@ -435,8 +516,13 @@ 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 });
update({ status: result.status });
if (result.status === "interrupted" || result.status === "running") {
const fromResume = reduceResumeOutput(result.output, result.status);
update({ status: result.status, ...fromResume });
const needsStream =
(result.status === "interrupted" || result.status === "running") &&
!fromResume.completedMarkdown &&
Object.keys(fromResume.draftedSections ?? {}).length === 0;
if (needsStream) {
await streamRun(session);
}
},
Expand Down Expand Up @@ -474,6 +560,7 @@ export function useWikiComposeSession(
// でも UI 側で再構築できるよう、フックでも軽量に持つ。
const completedMarkdown = useMemo(() => {
if (state.status !== "completed") return state.completedMarkdown;
if (state.completedMarkdown) return state.completedMarkdown;
const sections = state.outlineProposal
.map((s) => state.draftedSections[s.id])
.filter((s): s is DraftedSection => Boolean(s));
Expand Down
5 changes: 3 additions & 2 deletions src/lib/wikiCompose/composeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ export async function runSession(input: {
* - `human_review_outline` — `{ sections }`
*
* The server returns a JSON body `{ status, output }` on resume completion (no
* SSE stream — resume is fire-and-forget; the caller must `getSession` to
* refresh state, or call `runSession` again to follow the next phase live).
* SSE stream). Callers must hydrate UI state from `output` (interrupt payloads
* in `__interrupt__` or `completion` on the final outline approve). A follow-up
* `runSession` is only needed when `output` carries no wire payload.
*/
export async function resumeSession(input: {
pageId: string;
Expand Down
9 changes: 8 additions & 1 deletion src/pages/WikiComposePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Compose UI shell. The page reads the `useWikiComposeSession` hook for state
* and routes user submissions back through the hook's mutator methods.
*/
import React, { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, X } from "lucide-react";
import {
Expand Down Expand Up @@ -51,6 +51,13 @@ const WikiComposePage: React.FC = () => {
autoStart: Boolean(pageId),
});

// 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;
navigate(`/notes/${noteId}/${pageId}/compose/${id}`, { replace: true });
}, [session.session?.id, sessionId, noteId, pageId, navigate]);

const draftedSectionsById = useMemo(
() => indexById(Object.values(session.draftedSections)),
[session.draftedSections],
Expand Down
Loading