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
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",
);
});
});
72 changes: 72 additions & 0 deletions server/api/src/routes/composeSessionRunLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* 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.
* LangGraph 実行向け `POST /run` リクエスト前処理の結果。
*/
export type ComposeRunLocalePrep = {
contentLocale: ComposeContentLocale;
graphInput: unknown;
/**
* Metadata blob to persist on claim when locale is not yet stored.
* ロケール未保存時に claim 更新で永続化するメタデータ。
*/
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
20 changes: 20 additions & 0 deletions src/hooks/useWikiComposeSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -69,6 +76,19 @@ describe("useWikiComposeSession", () => {
mocks.createSession.mockResolvedValue(SESSION);
});

it("exposes canRetryStart after auto-start fails for fresh compose / 新規 Compose の自動開始失敗後に canRetryStart になる", async () => {
mocks.createSession.mockRejectedValue(new Error("network"));
const { result } = renderHook(() =>
useWikiComposeSession({
pageId: "page-1",
sessionId: null,
startPolicy: "when-backend-ready",
}),
);
await waitFor(() => expect(result.current.canRetryStart).toBe(true));
expect(result.current.error).toBe("network");
});

it("reduces a Brief interrupt into briefQuestions + pageSnapshot", async () => {
arrangeRun([
{ type: "started", sessionId: SESSION.id, graphId: SESSION.graphId },
Expand Down
Loading
Loading