Skip to content
Closed
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
44 changes: 44 additions & 0 deletions server/api/src/__tests__/routes/composeSessionRunLocale.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
66 changes: 66 additions & 0 deletions server/api/src/routes/composeSessionRunLocale.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | undefined;
};

function mergeSessionMetadataWithLocale(
metadata: unknown,
contentLocale: ComposeContentLocale,
): Record<string, unknown> {
const base =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? { ...(metadata as Record<string, unknown>) }
: {};
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);
}
36 changes: 13 additions & 23 deletions server/api/src/routes/composeSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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<string, unknown>) }
: {}),
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
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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"),
Expand Down
57 changes: 57 additions & 0 deletions src/hooks/useWikiComposeSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ 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: backendMock.backend,
isResolved: backendMock.isResolved,
}),
}));

import { useWikiComposeSession } from "./useWikiComposeSession";
import type { ComposeSseEvent } from "@/lib/wikiCompose/types";

Expand Down Expand Up @@ -61,6 +73,7 @@ function arrangeRun(events: ComposeSseEvent[]): void {

describe("useWikiComposeSession", () => {
beforeEach(() => {
backendMock.isResolved = true;
mocks.createSession.mockReset();
mocks.getSession.mockReset();
mocks.runSession.mockReset();
Expand Down Expand Up @@ -357,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<void>((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: {
Expand Down
Loading
Loading