From fb6da5eb779081ff0449147554dcd93dad8c8c4c Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 10 Jun 2026 19:31:44 +0800 Subject: [PATCH] fix(studio): extract public stability fixes from drama branch --- packages/cli/vitest.config.ts | 9 ++ packages/core/vitest.config.ts | 3 + packages/studio/src/App.test.ts | 10 +- packages/studio/src/App.tsx | 37 ++++++- packages/studio/src/api/server.test.ts | 13 +++ packages/studio/src/api/server.ts | 16 ++- .../src/hooks/use-book-activity.test.ts | 2 +- .../src/hooks/use-session-events.test.ts | 18 ++-- .../studio/src/hooks/use-session-events.ts | 99 ++++++++----------- packages/studio/src/hooks/use-sse.test.ts | 36 ++++++- packages/studio/src/hooks/use-sse.ts | 35 ++++++- packages/studio/vitest.config.ts | 3 + 12 files changed, 204 insertions(+), 77 deletions(-) create mode 100644 packages/cli/vitest.config.ts diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 00000000..02030b8e --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // CLI integration tests spawn real child processes; the default 5s timeout + // is too aggressive when the whole workspace runs in parallel. + testTimeout: 60_000, + }, +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 86960840..aa5b49ac 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -3,5 +3,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/__tests__/**/*.test.ts"], + // Some pipeline-runner tests can approach Vitest's default 5s timeout + // under full parallel runs; keep this high enough to avoid false kills. + testTimeout: 30_000, }, }); diff --git a/packages/studio/src/App.test.ts b/packages/studio/src/App.test.ts index a5ebcdeb..40a0730f 100644 --- a/packages/studio/src/App.test.ts +++ b/packages/studio/src/App.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { deriveActiveBookId, isBookCreateChatRoute } from "./App"; +import { deriveActiveBookId, deriveStartupGate, isBookCreateChatRoute } from "./App"; describe("deriveActiveBookId", () => { it("returns the current book across book-centered routes", () => { @@ -23,3 +23,11 @@ describe("isBookCreateChatRoute", () => { expect(isBookCreateChatRoute({ page: "book", bookId: "alpha" })).toBe(false); }); }); + +describe("deriveStartupGate", () => { + it("shows startup errors instead of spinning forever before the project is ready", () => { + expect(deriveStartupGate({ ready: false, projectError: null })).toBe("loading"); + expect(deriveStartupGate({ ready: false, projectError: "bad inkos.json" })).toBe("error"); + expect(deriveStartupGate({ ready: true, projectError: "later refetch failed" })).toBe("ready"); + }); +}); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index f54564e8..0d4ef498 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -39,12 +39,20 @@ export function isBookCreateChatRoute(route: HashRoute): boolean { return route.page === "book-create"; } +export function deriveStartupGate(input: { + readonly ready: boolean; + readonly projectError: string | null; +}): "ready" | "loading" | "error" { + if (input.ready) return "ready"; + return input.projectError ? "error" : "loading"; +} + export function App() { const { route, setRoute } = useHashRoute(); const sse = useSSE(); const { theme, setTheme } = useTheme(); const { t, lang: currentLang } = useI18n(); - const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project"); + const { data: project, error: projectError, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project"); const [showLanguageSelector, setShowLanguageSelector] = useState(false); const [ready, setReady] = useState(false); @@ -95,7 +103,32 @@ export function App() { ? "services" : route.page; - if (!ready) { + const startupGate = deriveStartupGate({ ready, projectError }); + + if (startupGate === "error") { + return ( +
+
+
+

无法加载项目配置 / Failed to load project config

+

{projectError}

+
+

+ 请检查项目根目录下的 inkos.json 是否存在且为合法 JSON,然后重试。 +

+ +
+
+ ); + } + + if (startupGate === "loading") { return (
diff --git a/packages/studio/src/api/server.test.ts b/packages/studio/src/api/server.test.ts index 7216150c..367b0f58 100644 --- a/packages/studio/src/api/server.test.ts +++ b/packages/studio/src/api/server.test.ts @@ -724,6 +724,19 @@ describe("createStudioServer daemon lifecycle", () => { }); }); + it("returns a structured config error when inkos.json is corrupt", async () => { + await writeFile(join(root, "inkos.json"), "{ this is not valid json", "utf-8"); + + const { createStudioServer } = await import("./server.js"); + const app = createStudioServer(cloneProjectConfig() as never, root); + + const response = await app.request("http://localhost/api/v1/project"); + expect(response.status).toBe(500); + const body = await response.json() as { error: { code: string; message: string } }; + expect(body.error.code).toBe("PROJECT_CONFIG_INVALID"); + expect(body.error.message).toContain("inkos.json"); + }); + it("reloads latest llm config for doctor checks without restarting the studio server", async () => { const startupConfig = { ...cloneProjectConfig(), diff --git a/packages/studio/src/api/server.ts b/packages/studio/src/api/server.ts index 8170d1fa..92a4b424 100644 --- a/packages/studio/src/api/server.ts +++ b/packages/studio/src/api/server.ts @@ -2678,9 +2678,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) { // --- Project info --- app.get("/api/v1/project", async (c) => { - const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false }); - // Check if language was explicitly set in inkos.json (not just the schema default) - const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")); + let currentConfig: ProjectConfig; + let raw: Record; + try { + currentConfig = await loadCurrentProjectConfig({ requireApiKey: false }); + // Check if language was explicitly set in inkos.json (not just the schema default) + raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8")) as Record; + } catch (error) { + throw new ApiError( + 500, + "PROJECT_CONFIG_INVALID", + `Failed to load inkos.json: ${error instanceof Error ? error.message : String(error)}`, + ); + } const languageExplicit = "language" in raw && raw.language !== ""; return c.json({ diff --git a/packages/studio/src/hooks/use-book-activity.test.ts b/packages/studio/src/hooks/use-book-activity.test.ts index ef5a3b25..117bf1cb 100644 --- a/packages/studio/src/hooks/use-book-activity.test.ts +++ b/packages/studio/src/hooks/use-book-activity.test.ts @@ -10,7 +10,7 @@ import { } from "./use-book-activity"; function msg(event: string, data: unknown, timestamp: number): SSEMessage { - return { event, data, timestamp }; + return { event, data, timestamp, seq: timestamp }; } describe("deriveBookActivity", () => { diff --git a/packages/studio/src/hooks/use-session-events.test.ts b/packages/studio/src/hooks/use-session-events.test.ts index fe4e8d69..673da09d 100644 --- a/packages/studio/src/hooks/use-session-events.test.ts +++ b/packages/studio/src/hooks/use-session-events.test.ts @@ -1,28 +1,26 @@ import { describe, expect, it } from "vitest"; import type { SSEMessage } from "./use-sse"; -import { takeUnprocessedSessionMessages } from "./use-session-events"; +import { collectNewSSEMessages } from "./use-sse"; function msg(event: string, timestamp: number, data: unknown = {}): SSEMessage { - return { event, timestamp, data }; + return { event, timestamp, data, seq: timestamp }; } -describe("takeUnprocessedSessionMessages", () => { - it("returns every newly appended message instead of only the last one", () => { - const seen = new WeakSet(); +describe("collectNewSSEMessages for session events", () => { + it("returns every event after the cursor instead of only the last one", () => { const created = msg("book:created", 1, { sessionId: "s1", bookId: "b1" }); const complete = msg("agent:complete", 2, { sessionId: "s1" }); - expect(takeUnprocessedSessionMessages([created, complete], seen)).toEqual([created, complete]); - expect(takeUnprocessedSessionMessages([created, complete], seen)).toEqual([]); + expect(collectNewSSEMessages([created, complete], 0).fresh).toEqual([created, complete]); + expect(collectNewSSEMessages([created, complete], 2).fresh).toEqual([]); }); it("still sees new events when the SSE ring buffer keeps the same length", () => { - const seen = new WeakSet(); const old1 = msg("agent:start", 1); const old2 = msg("agent:complete", 2); const next = msg("book:created", 3, { sessionId: "s1", bookId: "b1" }); - expect(takeUnprocessedSessionMessages([old1, old2], seen)).toEqual([old1, old2]); - expect(takeUnprocessedSessionMessages([old2, next], seen)).toEqual([next]); + expect(collectNewSSEMessages([old1, old2], 0).fresh).toEqual([old1, old2]); + expect(collectNewSSEMessages([old2, next], 2).fresh).toEqual([next]); }); }); diff --git a/packages/studio/src/hooks/use-session-events.ts b/packages/studio/src/hooks/use-session-events.ts index feb91d1f..b1c06d62 100644 --- a/packages/studio/src/hooks/use-session-events.ts +++ b/packages/studio/src/hooks/use-session-events.ts @@ -1,81 +1,64 @@ -import { useEffect, useRef } from "react"; import type { SSEMessage } from "./use-sse"; +import { useNewSSEMessages } from "./use-sse"; import type { HashRoute } from "./use-hash-route"; import { useChatStore } from "../store/chat"; import { bookKey, mergeSessionIds, updateSession } from "../store/chat/slices/message/runtime"; import { clearBookCreateSessionId, getBookCreateSessionId } from "../pages/chat-page-state"; -export function takeUnprocessedSessionMessages( - messages: ReadonlyArray, - seen: WeakSet, -): ReadonlyArray { - const pending: SSEMessage[] = []; - for (const message of messages) { - if (seen.has(message)) continue; - seen.add(message); - pending.push(message); - } - return pending; -} - /** * 监听全局 SSE 事件中与 session 有关的两类消息: * - session:title — AI 自动生成标题后推送,更新侧边栏显示 * - book:created — 新建书籍成功后推送,把 session 从 null 迁移到新书籍、清 localStorage、跳转 + * + * Cursor-based consumption matters because React may batch multiple SSE state + * updates into one render; looking only at messages.at(-1) drops middle events. */ export function useSessionEvents( sse: { messages: ReadonlyArray }, route: HashRoute, setRoute: (route: HashRoute) => void, ): void { - const seenMessages = useRef>(new WeakSet()); + useNewSSEMessages(sse.messages, (recent) => { + if (recent.event === "session:title") { + const data = recent.data as { sessionId?: string; title?: string } | null; + if (!data?.sessionId || !data.title) return; + const { sessionId, title } = data; + useChatStore.setState((state) => { + const session = state.sessions[sessionId]; + if (!session) return {}; + return { + sessions: updateSession(state.sessions, sessionId, () => ({ title })), + }; + }); + return; + } - useEffect(() => { - const pendingMessages = takeUnprocessedSessionMessages(sse.messages, seenMessages.current); - if (pendingMessages.length === 0) return; + if (recent.event === "book:created") { + const data = recent.data as { sessionId?: string; bookId?: string } | null; + if (!data?.sessionId || !data.bookId) return; + const { sessionId, bookId } = data; - for (const recent of pendingMessages) { - if (recent.event === "session:title") { - const data = recent.data as { sessionId?: string; title?: string } | null; - if (!data?.sessionId || !data.title) continue; - const { sessionId, title } = data; - useChatStore.setState((state) => { - const session = state.sessions[sessionId]; - if (!session) return {}; - return { - sessions: updateSession(state.sessions, sessionId, () => ({ title })), - }; - }); - continue; - } + useChatStore.setState((state) => { + const session = state.sessions[sessionId]; + if (!session) return {}; + const previousKey = bookKey(session.bookId); + const nextKey = bookKey(bookId); + return { + sessions: updateSession(state.sessions, sessionId, () => ({ bookId })), + sessionIdsByBook: { + ...state.sessionIdsByBook, + [previousKey]: (state.sessionIdsByBook[previousKey] ?? []).filter((id) => id !== sessionId), + [nextKey]: mergeSessionIds(state.sessionIdsByBook[nextKey], [sessionId]), + }, + }; + }); - if (recent.event === "book:created") { - const data = recent.data as { sessionId?: string; bookId?: string } | null; - if (!data?.sessionId || !data.bookId) continue; - const { sessionId, bookId } = data; - - useChatStore.setState((state) => { - const session = state.sessions[sessionId]; - if (!session) return {}; - const previousKey = bookKey(session.bookId); - const nextKey = bookKey(bookId); - return { - sessions: updateSession(state.sessions, sessionId, () => ({ bookId })), - sessionIdsByBook: { - ...state.sessionIdsByBook, - [previousKey]: (state.sessionIdsByBook[previousKey] ?? []).filter((id) => id !== sessionId), - [nextKey]: mergeSessionIds(state.sessionIdsByBook[nextKey], [sessionId]), - }, - }; - }); - - if (getBookCreateSessionId() === sessionId) { - clearBookCreateSessionId(); - if (route.page === "book-create") { - setRoute({ page: "book", bookId }); - } + if (getBookCreateSessionId() === sessionId) { + clearBookCreateSessionId(); + if (route.page === "book-create") { + setRoute({ page: "book", bookId }); } } } - }, [route.page, setRoute, sse.messages]); + }); } diff --git a/packages/studio/src/hooks/use-sse.test.ts b/packages/studio/src/hooks/use-sse.test.ts index 56b34924..62b8c376 100644 --- a/packages/studio/src/hooks/use-sse.test.ts +++ b/packages/studio/src/hooks/use-sse.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { STUDIO_SSE_EVENTS } from "./use-sse"; +import { STUDIO_SSE_EVENTS, collectNewSSEMessages } from "./use-sse"; +import type { SSEMessage } from "./use-sse"; describe("STUDIO_SSE_EVENTS", () => { it("covers the server lifecycle events that drive the UI", () => { @@ -50,3 +51,36 @@ describe("STUDIO_SSE_EVENTS", () => { ])); }); }); + +function msg(seq: number, event = "log"): SSEMessage { + return { event, data: null, timestamp: 1000 + seq, seq }; +} + +describe("collectNewSSEMessages", () => { + it("returns every message after the cursor, not just the last one", () => { + const messages = [msg(1), msg(2, "book:created"), msg(3, "session:title"), msg(4)]; + const { fresh, nextCursor } = collectNewSSEMessages(messages, 1); + expect(fresh.map((message) => message.seq)).toEqual([2, 3, 4]); + expect(nextCursor).toBe(4); + }); + + it("skips the backlog on first subscription and only sets the cursor", () => { + const messages = [msg(1), msg(2)]; + const { fresh, nextCursor } = collectNewSSEMessages(messages, null); + expect(fresh).toEqual([]); + expect(nextCursor).toBe(2); + }); + + it("keeps a null cursor while the buffer is empty", () => { + const { fresh, nextCursor } = collectNewSSEMessages([], null); + expect(fresh).toEqual([]); + expect(nextCursor).toBeNull(); + }); + + it("survives buffer trimming as long as retained messages are after the cursor", () => { + const messages = [msg(50), msg(51), msg(52)]; + const { fresh, nextCursor } = collectNewSSEMessages(messages, 49); + expect(fresh.map((message) => message.seq)).toEqual([50, 51, 52]); + expect(nextCursor).toBe(52); + }); +}); diff --git a/packages/studio/src/hooks/use-sse.ts b/packages/studio/src/hooks/use-sse.ts index c9c2afc5..47831fc7 100644 --- a/packages/studio/src/hooks/use-sse.ts +++ b/packages/studio/src/hooks/use-sse.ts @@ -4,6 +4,8 @@ export interface SSEMessage { readonly event: string; readonly data: unknown; readonly timestamp: number; + /** Monotonic sequence for cursor-based consumers; survives ring-buffer trimming. */ + readonly seq: number; } export const STUDIO_SSE_EVENTS = [ @@ -55,10 +57,37 @@ export const STUDIO_SSE_EVENTS = [ "ping", ] as const; +export function collectNewSSEMessages( + messages: ReadonlyArray, + cursor: number | null, +): { readonly fresh: ReadonlyArray; readonly nextCursor: number | null } { + if (messages.length === 0) return { fresh: [], nextCursor: cursor }; + const latest = messages[messages.length - 1]!.seq; + if (cursor === null) return { fresh: [], nextCursor: latest }; + if (latest <= cursor) return { fresh: [], nextCursor: cursor }; + return { fresh: messages.filter((message) => message.seq > cursor), nextCursor: latest }; +} + +export function useNewSSEMessages( + messages: ReadonlyArray, + handler: (message: SSEMessage) => void, +): void { + const cursorRef = useRef(null); + + useEffect(() => { + const { fresh, nextCursor } = collectNewSSEMessages(messages, cursorRef.current); + cursorRef.current = nextCursor; + for (const message of fresh) { + handler(message); + } + }, [handler, messages]); +} + export function useSSE(url = "/api/v1/events") { const [messages, setMessages] = useState>([]); const [connected, setConnected] = useState(false); const esRef = useRef(null); + const seqRef = useRef(0); useEffect(() => { const es = new EventSource(url); @@ -70,7 +99,11 @@ export function useSSE(url = "/api/v1/events") { const handleEvent = (e: MessageEvent) => { try { const data = e.data ? JSON.parse(e.data) : null; - setMessages((prev) => [...prev.slice(-99), { event: e.type, data, timestamp: Date.now() }]); + // Compute outside the state updater: React StrictMode may invoke + // updaters twice to verify purity. + seqRef.current += 1; + const message: SSEMessage = { event: e.type, data, timestamp: Date.now(), seq: seqRef.current }; + setMessages((prev) => [...prev.slice(-99), message]); } catch { // ignore parse errors } diff --git a/packages/studio/vitest.config.ts b/packages/studio/vitest.config.ts index 6cab567c..db515f63 100644 --- a/packages/studio/vitest.config.ts +++ b/packages/studio/vitest.config.ts @@ -11,5 +11,8 @@ export default defineConfig({ test: { include: ["src/**/*.test.ts"], fileParallelism: false, + // server.ts is large enough that first-load esbuild transforms can exceed + // Vitest's default 5s timeout on a cold full-suite run. + testTimeout: 30_000, }, });