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,
},
});