Procházet zdrojové kódy

Merge pull request #225 from SttFang/feature/pi-agent-jsonl-persistence

feat: add JSONL agent session persistence
hanjun fang před 6 měsíci
rodič
revize
8c3d1f7d80
44 změnil soubory, kde provedl 4568 přidání a 354 odebrání
  1. binární
      assets/wechat-group-v8.jpg
  2. 6 4
      packages/cli/src/__tests__/cli-integration.test.ts
  3. 29 0
      packages/core/src/__tests__/agent-max-tokens-policy.test.ts
  4. 664 50
      packages/core/src/__tests__/agent-session.test.ts
  5. 32 0
      packages/core/src/__tests__/agent-tools.test.ts
  6. 12 15
      packages/core/src/__tests__/architect.test.ts
  7. 134 2
      packages/core/src/__tests__/book-session-store.test.ts
  8. 1 1
      packages/core/src/__tests__/config-loader.test.ts
  9. 3 3
      packages/core/src/__tests__/effective-llm-config.test.ts
  10. 5 4
      packages/core/src/__tests__/pipeline-runner.test.ts
  11. 18 0
      packages/core/src/__tests__/planner.test.ts
  12. 15 7
      packages/core/src/__tests__/provider.test.ts
  13. 1 1
      packages/core/src/__tests__/providers-schema.test.ts
  14. 27 0
      packages/core/src/__tests__/service-resolver.test.ts
  15. 1309 0
      packages/core/src/__tests__/session-transcript-restore.test.ts
  16. 195 0
      packages/core/src/__tests__/session-transcript.test.ts
  17. 451 64
      packages/core/src/agent/agent-session.ts
  18. 31 9
      packages/core/src/agent/agent-tools.ts
  19. 3 3
      packages/core/src/agents/architect.ts
  20. 1 4
      packages/core/src/agents/length-normalizer.ts
  21. 1 1
      packages/core/src/agents/planner.ts
  22. 1 1
      packages/core/src/agents/polisher.ts
  23. 1 1
      packages/core/src/agents/reviser.ts
  24. 2 8
      packages/core/src/agents/writer.ts
  25. 33 0
      packages/core/src/index.ts
  26. 107 81
      packages/core/src/interaction/book-session-store.ts
  27. 113 0
      packages/core/src/interaction/session-transcript-legacy.ts
  28. 603 0
      packages/core/src/interaction/session-transcript-restore.ts
  29. 76 0
      packages/core/src/interaction/session-transcript-schema.ts
  30. 189 0
      packages/core/src/interaction/session-transcript.ts
  31. 9 5
      packages/core/src/llm/provider.ts
  32. 1 0
      packages/core/src/llm/providers/endpoints/deepseek.ts
  33. 3 3
      packages/core/src/llm/providers/endpoints/google.ts
  34. 4 1
      packages/core/src/llm/providers/types.ts
  35. 2 0
      packages/core/src/llm/service-presets.ts
  36. 18 0
      packages/core/src/llm/service-resolver.ts
  37. 205 4
      packages/studio/src/api/server.test.ts
  38. 170 77
      packages/studio/src/api/server.ts
  39. 13 3
      packages/studio/src/components/Sidebar.tsx
  40. 1 1
      packages/studio/src/hooks/use-api.ts
  41. 21 0
      packages/studio/src/hooks/use-book-activity.test.ts
  42. 56 0
      packages/studio/src/hooks/use-book-activity.ts
  43. 1 1
      packages/studio/src/pages/ChatPage.tsx
  44. 1 0
      packages/studio/src/store/chat/types.ts

binární
assets/wechat-group-v8.jpg


+ 6 - 4
packages/cli/src/__tests__/cli-integration.test.ts

@@ -9,6 +9,8 @@ import { StateManager } from "@actalk/inkos-core";
 const testDir = dirname(fileURLToPath(import.meta.url));
 const testDir = dirname(fileURLToPath(import.meta.url));
 const cliDir = resolve(testDir, "..", "..");
 const cliDir = resolve(testDir, "..", "..");
 const cliEntry = resolve(cliDir, "dist", "index.js");
 const cliEntry = resolve(cliDir, "dist", "index.js");
+const CLI_PROCESS_TIMEOUT_MS = 10_000;
+const DOUBLE_CLI_INVOCATION_TEST_TIMEOUT_MS = CLI_PROCESS_TIMEOUT_MS * 2;
 
 
 let projectDir: string;
 let projectDir: string;
 
 
@@ -35,7 +37,7 @@ function run(args: string[], options?: { env?: Record<string, string> }): string
     cwd: projectDir,
     cwd: projectDir,
     encoding: "utf-8",
     encoding: "utf-8",
     env: buildTestEnv(options?.env),
     env: buildTestEnv(options?.env),
-    timeout: 10_000,
+    timeout: CLI_PROCESS_TIMEOUT_MS,
   });
   });
 }
 }
 
 
@@ -45,7 +47,7 @@ function runStderr(args: string[], options?: { env?: Record<string, string> }):
       cwd: projectDir,
       cwd: projectDir,
       encoding: "utf-8",
       encoding: "utf-8",
       env: buildTestEnv(options?.env),
       env: buildTestEnv(options?.env),
-      timeout: 10_000,
+      timeout: CLI_PROCESS_TIMEOUT_MS,
     });
     });
     return { stdout, stderr: "", exitCode: 0 };
     return { stdout, stderr: "", exitCode: 0 };
   } catch (e: unknown) {
   } catch (e: unknown) {
@@ -432,7 +434,7 @@ describe("CLI integration", () => {
 
 
       const json = JSON.parse(run(["status", "degraded-status", "--json"]));
       const json = JSON.parse(run(["status", "degraded-status", "--json"]));
       expect(json.books[0]?.degraded).toBe(1);
       expect(json.books[0]?.degraded).toBe(1);
-    });
+    }, DOUBLE_CLI_INVOCATION_TEST_TIMEOUT_MS);
 
 
     it("shows a migration hint for legacy pre-v0.6 books", async () => {
     it("shows a migration hint for legacy pre-v0.6 books", async () => {
       const bookDir = join(projectDir, "books", "legacy-status-hint");
       const bookDir = join(projectDir, "books", "legacy-status-hint");
@@ -532,7 +534,7 @@ describe("CLI integration", () => {
 
 
       const json = JSON.parse(run(["status", bookId, "--json"]));
       const json = JSON.parse(run(["status", bookId, "--json"]));
       expect(json.books[0]?.chapters).toBe(1);
       expect(json.books[0]?.chapters).toBe(1);
-    });
+    }, DOUBLE_CLI_INVOCATION_TEST_TIMEOUT_MS);
   });
   });
 
 
   describe("inkos doctor", () => {
   describe("inkos doctor", () => {

+ 29 - 0
packages/core/src/__tests__/agent-max-tokens-policy.test.ts

@@ -0,0 +1,29 @@
+import { readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const AGENT_FILES = [
+  "../agents/architect.ts",
+  "../agents/length-normalizer.ts",
+  "../agents/planner.ts",
+  "../agents/polisher.ts",
+  "../agents/reviser.ts",
+  "../agents/writer.ts",
+] as const;
+
+describe("creative agent maxTokens policy", () => {
+  it("lets modelCard defaults own generation output budgets", async () => {
+    const testDir = dirname(fileURLToPath(import.meta.url));
+    const offenders: string[] = [];
+
+    for (const relativePath of AGENT_FILES) {
+      const source = await readFile(join(testDir, relativePath), "utf-8");
+      if (/\bmaxTokens\s*:/.test(source)) {
+        offenders.push(relativePath);
+      }
+    }
+
+    expect(offenders).toEqual([]);
+  });
+});

+ 664 - 50
packages/core/src/__tests__/agent-session.test.ts

@@ -3,74 +3,157 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 
 
-const { agentInstances } = vi.hoisted(() => ({
+const EMPTY_USAGE = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+const { agentInstances, streamCalls, heldStreamCompletions, heldStreamWaiters } = vi.hoisted(() => ({
   agentInstances: [] as any[],
   agentInstances: [] as any[],
+  streamCalls: [] as Array<{ model: any; context: any }>,
+  heldStreamCompletions: [] as Array<() => void>,
+  heldStreamWaiters: [] as Array<() => void>,
 }));
 }));
 
 
 vi.mock("@mariozechner/pi-agent-core", async () => {
 vi.mock("@mariozechner/pi-agent-core", async () => {
   const actual = await vi.importActual<any>("@mariozechner/pi-agent-core");
   const actual = await vi.importActual<any>("@mariozechner/pi-agent-core");
-  class FakeAgent {
-    state: any;
-    transformContext: any;
-    streamFn: any;
-    getApiKey: any;
+  class SpyAgent extends actual.Agent {
     constructor(options: any) {
     constructor(options: any) {
-      this.state = {
-        model: options.initialState?.model,
-        systemPrompt: options.initialState?.systemPrompt,
-        tools: options.initialState?.tools ?? [],
-        messages: [],
-      };
-      this.transformContext = options.transformContext;
-      this.streamFn = options.streamFn;
-      this.getApiKey = options.getApiKey;
+      super(options);
       agentInstances.push(this);
       agentInstances.push(this);
     }
     }
-    subscribe() {
-      return () => {};
-    }
-    async prompt(userMessage: string) {
-      const now = Date.now();
-      this.state.messages.push({ role: "user", content: userMessage, timestamp: now });
-      this.state.messages.push({
-        role: "assistant",
-        content: [{ type: "text", text: "ok" }],
-        api: "anthropic-messages",
-        provider: "anthropic",
-        model: "fake",
-        usage: {
-          input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
-          cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
-        },
-        stopReason: "stop",
-        timestamp: now + 1,
-      });
-    }
   }
   }
-  return { ...actual, Agent: FakeAgent };
+  return { ...actual, Agent: SpyAgent };
 });
 });
 
 
 vi.mock("@mariozechner/pi-ai", async () => {
 vi.mock("@mariozechner/pi-ai", async () => {
   const actual = await vi.importActual<any>("@mariozechner/pi-ai");
   const actual = await vi.importActual<any>("@mariozechner/pi-ai");
+
+  function clone(value: unknown): unknown {
+    return JSON.parse(JSON.stringify(value));
+  }
+
+  function textFromContent(content: unknown): string {
+    if (typeof content === "string") return content;
+    if (!Array.isArray(content)) return "";
+    return content
+      .filter((block: any) => block?.type === "text" && typeof block.text === "string")
+      .map((block: any) => block.text)
+      .join("");
+  }
+
+  function lastVisibleUserText(messages: any[]): string {
+    for (let index = messages.length - 1; index >= 0; index -= 1) {
+      const message = messages[index];
+      if (message?.role === "user") return textFromContent(message.content);
+    }
+    return "";
+  }
+
+  function assistant(content: any[], timestamp = Date.now()) {
+    return {
+      role: "assistant",
+      content,
+      api: "anthropic-messages",
+      provider: "anthropic",
+      model: "fake",
+      usage: EMPTY_USAGE,
+      stopReason: content.some((block) => block.type === "toolCall") ? "toolUse" : "stop",
+      timestamp,
+    };
+  }
+
+  const streamSimple = vi.fn((model: any, context: any) => {
+    streamCalls.push({ model: clone(model), context: clone(context) });
+    const stream = actual.createAssistantMessageEventStream();
+    const last = context.messages.at(-1);
+    const prompt = lastVisibleUserText(context.messages);
+    const timestamp = Date.now();
+    const message = last?.role === "toolResult"
+      ? assistant([{ type: "text", text: "ok" }], timestamp)
+      : prompt === "model error"
+        ? {
+            role: "assistant",
+            content: [],
+            api: "anthropic-messages",
+            provider: "anthropic",
+            model: "fake",
+            usage: EMPTY_USAGE,
+            stopReason: "error",
+            errorMessage: "400 status code (no body)",
+            timestamp,
+          }
+        : prompt === "think"
+        ? assistant([
+            { type: "thinking", thinking: "raw thought", thinkingSignature: "sig-1" },
+            { type: "text", text: "ok" },
+          ], timestamp)
+        : prompt === "use tool"
+          ? assistant([
+              {
+                type: "toolCall",
+                id: "tool-1",
+                name: "read",
+                arguments: { path: "book-a/story/story_bible.md" },
+              },
+            ], timestamp)
+          : assistant([{ type: "text", text: "ok" }], timestamp);
+
+    const done = () => stream.push({
+      type: "done",
+      reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
+      message,
+    });
+    if (prompt === "hold for interleave") {
+      heldStreamCompletions.push(done);
+      heldStreamWaiters.splice(0).forEach((resolve) => resolve());
+    } else if (prompt.startsWith("slow ")) {
+      setTimeout(done, prompt.includes("first") ? 20 : 0);
+    } else {
+      done();
+    }
+    return stream;
+  });
+
   return {
   return {
     ...actual,
     ...actual,
-    streamSimple: vi.fn(),
+    streamSimple,
     getEnvApiKey: vi.fn(() => "fake-key"),
     getEnvApiKey: vi.fn(() => "fake-key"),
     getModel: vi.fn((provider: string, id: string) => ({
     getModel: vi.fn((provider: string, id: string) => ({
       provider,
       provider,
       id,
       id,
+      name: id,
       api: "anthropic-messages",
       api: "anthropic-messages",
+      baseUrl: "",
+      reasoning: false,
+      input: ["text"],
+      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+      contextWindow: 200_000,
+      maxTokens: 4096,
     })),
     })),
   };
   };
 });
 });
 
 
 import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
 import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
+import {
+  appendManualSessionMessages,
+  appendTranscriptEvent,
+  appendTranscriptEvents,
+  readTranscriptEvents,
+} from "../interaction/session-transcript.js";
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 
 
 describe("runAgentSession cache — bookId switch", () => {
 describe("runAgentSession cache — bookId switch", () => {
   let projectRoot: string;
   let projectRoot: string;
+  let otherProjectRoot: string | null;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
     projectRoot = await mkdtemp(join(tmpdir(), "inkos-agent-cache-"));
     projectRoot = await mkdtemp(join(tmpdir(), "inkos-agent-cache-"));
+    otherProjectRoot = null;
     await mkdir(join(projectRoot, "books", "book-a", "story"), { recursive: true });
     await mkdir(join(projectRoot, "books", "book-a", "story"), { recursive: true });
     await writeFile(
     await writeFile(
       join(projectRoot, "books", "book-a", "story", "story_bible.md"),
       join(projectRoot, "books", "book-a", "story", "story_bible.md"),
@@ -82,11 +165,19 @@ describe("runAgentSession cache — bookId switch", () => {
       "书B 的真相",
       "书B 的真相",
     );
     );
     agentInstances.length = 0;
     agentInstances.length = 0;
+    streamCalls.length = 0;
+    heldStreamCompletions.length = 0;
+    heldStreamWaiters.length = 0;
   });
   });
 
 
   afterEach(async () => {
   afterEach(async () => {
     evictAgentCache("s1");
     evictAgentCache("s1");
+    evictAgentCache("s-cache-seq");
+    evictAgentCache("s-error");
+    evictAgentCache("s-project-root-cache");
+    evictAgentCache("s-interleave-seq");
     await rm(projectRoot, { recursive: true, force: true });
     await rm(projectRoot, { recursive: true, force: true });
+    if (otherProjectRoot) await rm(otherProjectRoot, { recursive: true, force: true });
   });
   });
 
 
   it("rebuilds Agent when bookId changes for same sessionId", async () => {
   it("rebuilds Agent when bookId changes for same sessionId", async () => {
@@ -106,17 +197,10 @@ describe("runAgentSession cache — bookId switch", () => {
 
 
     expect(agentInstances).toHaveLength(2);
     expect(agentInstances).toHaveLength(2);
 
 
-    const injected = await agentInstances[1].transformContext([]);
-    const body = JSON.stringify(injected);
+    const body = JSON.stringify(streamCalls.at(-1)?.context.messages);
     expect(body).toContain("书B 的真相");
     expect(body).toContain("书B 的真相");
     expect(body).not.toContain("书A 的真相");
     expect(body).not.toContain("书A 的真相");
-
-    // Prior conversation must be replayed into the rebuilt Agent via initialMessages,
-    // not silently dropped — this is the whole reason eviction preserves messages.
-    const preservedUser = agentInstances[1].state.messages.find(
-      (m: any) => m.role === "user" && typeof m.content === "string" && m.content.includes("earlier question about book A"),
-    );
-    expect(preservedUser).toBeDefined();
+    expect(body).toContain("earlier question about book A");
   });
   });
 
 
   it("rebuilds Agent when bookId goes from null to a real book", async () => {
   it("rebuilds Agent when bookId goes from null to a real book", async () => {
@@ -135,8 +219,7 @@ describe("runAgentSession cache — bookId switch", () => {
     );
     );
 
 
     expect(agentInstances).toHaveLength(2);
     expect(agentInstances).toHaveLength(2);
-    const injected = await agentInstances[1].transformContext([]);
-    expect(JSON.stringify(injected)).toContain("书A 的真相");
+    expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).toContain("书A 的真相");
   });
   });
 
 
   it("treats undefined bookId as null (no spurious rebuild)", async () => {
   it("treats undefined bookId as null (no spurious rebuild)", async () => {
@@ -149,8 +232,6 @@ describe("runAgentSession cache — bookId switch", () => {
     );
     );
     expect(agentInstances).toHaveLength(1);
     expect(agentInstances).toHaveLength(1);
 
 
-    // A caller passing `undefined` (e.g., `activeBookId` not set and no `?? null`
-    // guard) must not cause an eviction when the cached agent already holds null.
     await runAgentSession(
     await runAgentSession(
       { sessionId: "s1", bookId: undefined as any, language: "zh", pipeline, projectRoot, model },
       { sessionId: "s1", bookId: undefined as any, language: "zh", pipeline, projectRoot, model },
       "hi",
       "hi",
@@ -174,4 +255,537 @@ describe("runAgentSession cache — bookId switch", () => {
 
 
     expect(agentInstances).toHaveLength(1);
     expect(agentInstances).toHaveLength(1);
   });
   });
+
+  it("keeps cached Agents isolated by projectRoot for the same sessionId", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    otherProjectRoot = await mkdtemp(join(tmpdir(), "inkos-agent-cache-other-"));
+    await mkdir(join(otherProjectRoot, "books", "book-a", "story"), { recursive: true });
+    await writeFile(
+      join(otherProjectRoot, "books", "book-a", "story", "story_bible.md"),
+      "另一个 projectRoot 的真相",
+    );
+
+    await runAgentSession(
+      { sessionId: "s-project-root-cache", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "root A",
+    );
+    await runAgentSession(
+      {
+        sessionId: "s-project-root-cache",
+        bookId: "book-a",
+        language: "zh",
+        pipeline,
+        projectRoot: otherProjectRoot,
+        model,
+      },
+      "root B",
+    );
+    await runAgentSession(
+      { sessionId: "s-project-root-cache", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "root A again",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    const body = JSON.stringify(streamCalls.at(-1)?.context.messages);
+    expect(body).toContain("书A 的真相");
+    expect(body).not.toContain("另一个 projectRoot 的真相");
+  });
+
+  it("rebuilds Agent when model id is unchanged but API protocol changes", async () => {
+    const pipeline = {} as any;
+    const legacyGoogle = {
+      provider: "openai",
+      id: "gemini-pro-latest",
+      api: "openai-completions",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      input: ["text"],
+    } as any;
+    const nativeGoogle = {
+      provider: "google",
+      id: "gemini-pro-latest",
+      api: "google-generative-ai",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta",
+      input: ["text"],
+    } as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model: legacyGoogle },
+      "hi",
+    );
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model: nativeGoogle },
+      "hi2",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    expect(streamCalls.at(-1)?.model.api).toBe("google-generative-ai");
+    expect(streamCalls.at(-1)?.model.provider).toBe("google");
+  });
+
+  it("rebuilds Agent when model baseUrl changes", async () => {
+    const pipeline = {} as any;
+    const first = { provider: "openai", id: "same-model", api: "openai-completions", baseUrl: "https://one.example/v1", input: ["text"] } as any;
+    const second = { provider: "openai", id: "same-model", api: "openai-completions", baseUrl: "https://two.example/v1", input: ["text"] } as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model: first },
+      "hi",
+    );
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model: second },
+      "hi2",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    expect(streamCalls.at(-1)?.model.baseUrl).toBe("https://two.example/v1");
+  });
+
+  it("rebuilds cached Agent when transcript committed seq changes outside cache", async () => {
+    const model = { provider: "anthropic", id: "fake", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s-cache-seq", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "hello",
+    );
+
+    await appendManualSessionMessages(projectRoot, "s-cache-seq", [{
+      role: "assistant",
+      content: [{ type: "text", text: "manual fallback persisted" }],
+      api: "anthropic-messages",
+      provider: "anthropic",
+      model: "fake",
+      usage: EMPTY_USAGE,
+      stopReason: "stop",
+      timestamp: Date.now(),
+    } as any], "fallback-input");
+
+    await runAgentSession(
+      { sessionId: "s-cache-seq", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "again",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).toContain("manual fallback persisted");
+  });
+
+  it("enables system file read by default for the session read tool", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    const outsidePath = join(projectRoot, "outside.md");
+    await writeFile(outsidePath, "outside content", "utf-8");
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: null, language: "zh", pipeline, projectRoot, model },
+      "hi",
+    );
+
+    const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
+    const result = await readTool.execute("tool-read-default-session", { path: outsidePath });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("outside content");
+    }
+  });
+
+  it("can explicitly disable system file read for the session read tool", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    const outsidePath = join(projectRoot, "outside.md");
+    await writeFile(outsidePath, "outside content", "utf-8");
+
+    await runAgentSession(
+      {
+        sessionId: "s1",
+        bookId: null,
+        language: "zh",
+        pipeline,
+        projectRoot,
+        model,
+        allowSystemFileRead: false,
+      },
+      "hi",
+    );
+
+    const readTool = agentInstances[0].state.tools.find((tool: any) => tool.name === "read");
+    const result = await readTool.execute("tool-read-disabled-session", { path: outsidePath });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("Path traversal blocked");
+      expect(result.content[0].text).not.toContain("outside content");
+    }
+  });
+
+  it("把真实 Agent 的 message_end 写入 JSONL,并在 cache 失效后恢复 raw AgentMessage", async () => {
+    const model = { provider: "anthropic", id: "fake", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "think",
+    );
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events.map((event) => event.type)).toContain("request_committed");
+
+    evictAgentCache("s1");
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "again",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).toContain("raw thought");
+    expect(streamCalls.at(-1)?.context.messages).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({ role: "assistant" }),
+      ]),
+    );
+  });
+
+  it("恢复 transcript 中的 toolResult message", async () => {
+    const model = { provider: "anthropic", id: "fake", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "use tool",
+    );
+
+    evictAgentCache("s1");
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "again",
+    );
+
+    expect(agentInstances).toHaveLength(2);
+    expect(streamCalls.at(-1)?.context.messages.some(
+      (message: any) => message.role === "toolResult" && message.toolCallId === "tool-1",
+    )).toBe(true);
+
+    const messageEvents = (await readTranscriptEvents(projectRoot, "s1"))
+      .filter((event) => event.type === "message");
+    const toolAssistant = messageEvents.find(
+      (event: any) => event.toolCallId === "tool-1" && event.role === "assistant",
+    ) as any;
+    const toolResult = messageEvents.find(
+      (event: any) => event.toolCallId === "tool-1" && event.role === "toolResult",
+    ) as any;
+    expect(toolResult.sourceToolAssistantUuid).toBe(toolAssistant.uuid);
+  });
+
+  it("Gemini OpenAI-compatible 模型不向 LLM replay 原生 toolCall/toolResult 历史", async () => {
+    const model = {
+      provider: "google",
+      id: "gemini-pro-latest",
+      api: "openai-completions",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      input: ["text"],
+    } as any;
+    const pipeline = {} as any;
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "use tool",
+    );
+
+    const lastContextMessages = streamCalls.at(-1)?.context.messages ?? [];
+    const body = JSON.stringify(lastContextMessages);
+
+    expect(lastContextMessages.some((message: any) => message.role === "toolResult")).toBe(false);
+    expect(body).not.toContain("\"toolCall\"");
+    expect(lastContextMessages).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          role: "user",
+          content: expect.stringContaining("[Tool results]"),
+        }),
+      ]),
+    );
+    expect(body).toContain("read");
+    expect(body).toContain("tool-1");
+    expect(body).toContain("书A 的真相");
+  });
+
+  it("Gemini OpenAI-compatible 上下文过滤恢复时补出的 toolResult bridge", async () => {
+    const model = {
+      provider: "google",
+      id: "gemini-pro-latest",
+      api: "openai-completions",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      input: ["text"],
+    } as any;
+    const pipeline = {} as any;
+
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "use tool",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 2,
+      role: "user",
+      timestamp: 2,
+      message: { role: "user", content: "use tool", timestamp: 2 },
+    } as any);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 3,
+      role: "assistant",
+      timestamp: 3,
+      toolCallId: "tool-1",
+      message: {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "book-a/story/story_bible.md" } }],
+        api: "openai-completions",
+        provider: "openai",
+        model: "gemini-pro-latest",
+        usage: EMPTY_USAGE,
+        stopReason: "toolUse",
+        timestamp: 3,
+      },
+    } as any);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 4,
+      role: "toolResult",
+      timestamp: 4,
+      toolCallId: "tool-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 4,
+      },
+    } as any);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "again",
+    );
+
+    const body = JSON.stringify(streamCalls.at(-1)?.context.messages ?? []);
+    expect(body).not.toContain("I have processed the tool results.");
+    expect(body).toContain("[Tool results]");
+    expect(body).toContain("资料");
+  });
+
+  it("切到 DeepSeek 时不 replay 其他模型的原生 toolCall/toolResult 历史", async () => {
+    const pipeline = {} as any;
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "use tool",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: null,
+      seq: 2,
+      role: "assistant",
+      timestamp: 2,
+      toolCallId: "tool-1",
+      message: {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "book-a/story/story_bible.md" } }],
+        api: "openai-completions",
+        provider: "openai",
+        model: "gemini-pro-latest",
+        usage: EMPTY_USAGE,
+        stopReason: "toolUse",
+        timestamp: 2,
+      },
+    } as any);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 3,
+      role: "toolResult",
+      timestamp: 3,
+      toolCallId: "tool-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 3,
+      },
+    } as any);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 4,
+      timestamp: 4,
+    });
+
+    await runAgentSession(
+      {
+        sessionId: "s1",
+        bookId: "book-a",
+        language: "zh",
+        pipeline,
+        projectRoot,
+        model: { provider: "openai", id: "deepseek-v4-pro", api: "openai-completions", input: ["text"] } as any,
+      },
+      "again",
+    );
+
+    const messages = streamCalls.at(-1)?.context.messages ?? [];
+    const body = JSON.stringify(messages);
+    expect(body).not.toContain("\"toolCall\"");
+    expect(messages.some((message: any) => message.role === "toolResult")).toBe(false);
+    expect(body).toContain("[Tool results]");
+    expect(body).toContain("资料");
+  });
+
+  it("final assistant error writes request_failed instead of request_committed", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    const result = await runAgentSession(
+      { sessionId: "s-error", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "model error",
+    );
+
+    expect(result.responseText).toBe("");
+    expect(result.errorMessage).toBe("400 status code (no body)");
+
+    const events = await readTranscriptEvents(projectRoot, "s-error");
+    expect(events.map((event) => event.type)).toContain("request_failed");
+    expect(events.map((event) => event.type)).not.toContain("request_committed");
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s-error");
+    expect(restored).toEqual([]);
+
+    const instancesAfterError = agentInstances.length;
+    await runAgentSession(
+      { sessionId: "s-error", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "again",
+    );
+    expect(agentInstances).toHaveLength(instancesAfterError + 1);
+    expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).not.toContain("model error");
+  });
+
+  it("serializes concurrent turns before assigning transcript seq", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    await Promise.all([
+      runAgentSession(
+        { sessionId: "s-turn-race", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+        "slow first",
+      ),
+      runAgentSession(
+        { sessionId: "s-turn-race", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+        "slow second",
+      ),
+    ]);
+
+    const events = await readTranscriptEvents(projectRoot, "s-turn-race");
+
+    expect(events.filter((event) => event.type === "session_created")).toHaveLength(1);
+    expect(events.filter((event) => event.type === "request_committed")).toHaveLength(2);
+    expect(events.map((event) => event.seq)).toEqual(events.map((_, index) => index + 1));
+  });
+
+  it("assigns transcript seq after interleaved non-agent writes", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+    let resolveTurnStarted!: () => void;
+    const turnStarted = new Promise<void>((resolve) => {
+      resolveTurnStarted = resolve;
+    });
+    let interleavedWrite: Promise<unknown> | null = null;
+
+    const running = runAgentSession(
+      {
+        sessionId: "s-interleave-seq",
+        bookId: "book-a",
+        language: "zh",
+        pipeline,
+        projectRoot,
+        model,
+        onEvent: (event) => {
+          if (event.type !== "turn_start" || interleavedWrite) return;
+          interleavedWrite = appendTranscriptEvents(projectRoot, "s-interleave-seq", ({ nextSeq }) => [{
+            type: "session_metadata_updated",
+            version: 1,
+            sessionId: "s-interleave-seq",
+            seq: nextSeq,
+            timestamp: Date.now(),
+            updatedAt: Date.now(),
+            title: "interleaved update",
+          }]);
+          resolveTurnStarted();
+        },
+      },
+      "hold for interleave",
+    );
+
+    await turnStarted;
+    await interleavedWrite;
+    if (heldStreamCompletions.length === 0) {
+      await new Promise<void>((resolve) => {
+        heldStreamWaiters.push(resolve);
+      });
+    }
+    const finishStream = heldStreamCompletions.shift();
+    expect(finishStream).toBeTypeOf("function");
+    finishStream?.();
+    await running;
+
+    const events = await readTranscriptEvents(projectRoot, "s-interleave-seq");
+    expect(events.map((event) => event.seq)).toEqual(events.map((_, index) => index + 1));
+  });
 });
 });

+ 32 - 0
packages/core/src/__tests__/agent-tools.test.ts

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
 import { join } from "node:path";
 import { join } from "node:path";
 import { StateManager } from "../state/manager.js";
 import { StateManager } from "../state/manager.js";
 import {
 import {
+  createReadTool,
   createSubAgentTool,
   createSubAgentTool,
   createPatchChapterTextTool,
   createPatchChapterTextTool,
   createRenameEntityTool,
   createRenameEntityTool,
@@ -200,6 +201,37 @@ describe("agent deterministic writing tools", () => {
     }
     }
   });
   });
 
 
+  it("keeps read tool scoped to books by default", async () => {
+    const outsidePath = join(root, "outside.md");
+    await writeFile(outsidePath, "outside secret", "utf-8");
+    const tool = createReadTool(root);
+
+    const result = await tool.execute("tool-read-default", {
+      path: outsidePath,
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("Path traversal blocked");
+      expect(result.content[0].text).not.toContain("outside secret");
+    }
+  });
+
+  it("reads absolute system paths when explicitly enabled", async () => {
+    const outsidePath = join(root, "outside.md");
+    await writeFile(outsidePath, "outside secret", "utf-8");
+    const tool = createReadTool(root, { allowSystemPaths: true });
+
+    const result = await tool.execute("tool-read-system", {
+      path: outsidePath,
+    });
+
+    expect(result.content[0]?.type).toBe("text");
+    if (result.content[0]?.type === "text") {
+      expect(result.content[0].text).toContain("outside secret");
+    }
+  });
+
   it("creates nested files through the generic write tool", async () => {
   it("creates nested files through the generic write tool", async () => {
     const tool = createWriteFileTool(root);
     const tool = createWriteFileTool(root);
 
 

+ 12 - 15
packages/core/src/__tests__/architect.test.ts

@@ -524,7 +524,7 @@ describe("ArchitectAgent", () => {
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
     await expect(agent.generateFoundation(book)).rejects.toThrow(/book_rules/i);
   });
   });
 
 
-  it("passes maxTokens 16384 when generating foundation", async () => {
+  it("uses modelCard output budget when generating foundation", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -581,13 +581,12 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFoundation(book);
     await agent.generateFoundation(book);
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.8, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.8 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
-  it("passes maxTokens 16384 when generating foundation from import", async () => {
+  it("uses modelCard output budget when generating foundation from import", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -644,13 +643,12 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFoundationFromImport(book, "第一章正文");
     await agent.generateFoundationFromImport(book, "第一章正文");
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.5, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.5 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
-  it("passes maxTokens 16384 when generating fanfic foundation", async () => {
+  it("uses modelCard output budget when generating fanfic foundation", async () => {
     const agent = new ArchitectAgent({
     const agent = new ArchitectAgent({
       client: {
       client: {
         provider: "openai",
         provider: "openai",
@@ -707,10 +705,9 @@ describe("ArchitectAgent", () => {
 
 
     await agent.generateFanficFoundation(book, "正典文本", "canon");
     await agent.generateFanficFoundation(book, "正典文本", "canon");
 
 
-    expect(chatSpy).toHaveBeenCalledWith(
-      expect.any(Array),
-      expect.objectContaining({ temperature: 0.7, maxTokens: 20480 }),
-    );
+    const options = chatSpy.mock.calls[0]?.[1] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.7 }));
+    expect(options).not.toHaveProperty("maxTokens");
   });
   });
 
 
   // ---- Phase 5 段落式架构稿专项 ----
   // ---- Phase 5 段落式架构稿专项 ----

+ 134 - 2
packages/core/src/__tests__/book-session-store.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it, beforeEach, afterEach } from "vitest";
 import { describe, expect, it, beforeEach, afterEach } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import {
 import {
@@ -9,11 +9,18 @@ import {
   renameBookSession,
   renameBookSession,
   deleteBookSession,
   deleteBookSession,
   migrateBookSession,
   migrateBookSession,
+  createAndPersistBookSession,
   extractFirstUserMessageTitle,
   extractFirstUserMessageTitle,
   SessionAlreadyMigratedError,
   SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 } from "../interaction/book-session-store.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
-import { mkdir, writeFile } from "node:fs/promises";
+import {
+  appendManualSessionMessages,
+  legacyBookSessionPath,
+  readTranscriptEvents,
+  sessionsDir,
+} from "../interaction/session-transcript.js";
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 
 
 describe("book-session-store", () => {
 describe("book-session-store", () => {
   let tempDir: string;
   let tempDir: string;
@@ -82,6 +89,104 @@ describe("book-session-store", () => {
       const loaded = await loadBookSession(tempDir, session.sessionId);
       const loaded = await loadBookSession(tempDir, session.sessionId);
       expect(loaded!.title).toBe("测试标题");
       expect(loaded!.title).toBe("测试标题");
     });
     });
+
+    it("读取 legacy JSON 时迁移为 JSONL 并保留 UI thinking", async () => {
+      const session = {
+        ...createBookSession("book-a", "legacy-1"),
+        messages: [
+          { role: "user" as const, content: "继续", timestamp: 10 },
+          { role: "assistant" as const, content: "好的", thinking: "旧思考", timestamp: 11 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      const loaded = await loadBookSession(tempDir, "legacy-1");
+
+      expect(loaded).toMatchObject({
+        sessionId: "legacy-1",
+        bookId: "book-a",
+        messages: [
+          { role: "user", content: "继续" },
+          { role: "assistant", content: "好的", thinking: "旧思考" },
+        ],
+      });
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "legacy-1.jsonl"), "utf-8"))
+        .resolves
+        .toContain("request_committed");
+    });
+
+    it("does not duplicate legacy messages when migration runs concurrently", async () => {
+      const legacy = createBookSession("book-a", "legacy-race");
+      const withMessages = {
+        ...legacy,
+        messages: [
+          { role: "user" as const, content: "old user", timestamp: legacy.createdAt },
+          { role: "assistant" as const, content: "old assistant", timestamp: legacy.createdAt + 1 },
+        ],
+      };
+      await mkdir(sessionsDir(tempDir), { recursive: true });
+      await writeFile(
+        legacyBookSessionPath(tempDir, "legacy-race"),
+        JSON.stringify(withMessages, null, 2),
+        "utf-8",
+      );
+
+      const [first, second] = await Promise.all([
+        loadBookSession(tempDir, "legacy-race"),
+        loadBookSession(tempDir, "legacy-race"),
+      ]);
+
+      expect(first?.messages).toHaveLength(2);
+      expect(second?.messages).toHaveLength(2);
+
+      const restored = await restoreAgentMessagesFromTranscript(tempDir, "legacy-race");
+      expect(restored.map((message) => message.role)).toEqual(["user", "assistant"]);
+    });
+
+    it("createAndPersistBookSession 为新 session 写 JSONL 而不是 legacy JSON", async () => {
+      const session = await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+
+      expect(session.sessionId).toBe("123456-abcdef");
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.jsonl"), "utf-8"))
+        .resolves
+        .toContain("session_created");
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.json"), "utf-8"))
+        .rejects
+        .toThrow();
+    });
+
+    it("does not duplicate session_created when explicit session creation races", async () => {
+      await Promise.all([
+        createAndPersistBookSession(tempDir, "book-a", "create-race"),
+        createAndPersistBookSession(tempDir, "book-a", "create-race"),
+      ]);
+
+      const events = await readTranscriptEvents(tempDir, "create-race");
+      expect(events.filter((event) => event.type === "session_created")).toHaveLength(1);
+      expect(events.map((event) => event.seq)).toEqual([1]);
+    });
+
+    it("renameBookSession 追加 metadata event 并从 JSONL 派生新标题", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+
+      const renamed = await renameBookSession(tempDir, "123456-abcdef", "新标题");
+
+      expect(renamed!.title).toBe("新标题");
+      const loaded = await loadBookSession(tempDir, "123456-abcdef");
+      expect(loaded!.title).toBe("新标题");
+    });
+
+    it("listBookSessions 同时列出 JSONL session 和未迁移 legacy JSON session", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+      const dir = join(tempDir, ".inkos", "sessions");
+      await mkdir(dir, { recursive: true });
+      const legacy = { ...createBookSession("book-a", "legacy-1"), updatedAt: 999 };
+      await writeFile(join(dir, "legacy-1.json"), JSON.stringify(legacy));
+
+      const list = await listBookSessions(tempDir, "book-a");
+
+      expect(list.map((entry) => entry.sessionId).sort()).toEqual(["123456-abcdef", "legacy-1"]);
+    });
   });
   });
 
 
   describe("listBookSessions", () => {
   describe("listBookSessions", () => {
@@ -120,6 +225,20 @@ describe("book-session-store", () => {
       expect(list[2].updatedAt).toBe(100);
       expect(list[2].updatedAt).toBe(100);
     });
     });
 
 
+    it("updates session activity time from committed chat messages", async () => {
+      const session = await createAndPersistBookSession(tempDir, "book", "chat-activity");
+      await new Promise((resolve) => setTimeout(resolve, 5));
+
+      await appendManualSessionMessages(tempDir, session.sessionId, [{
+        role: "user",
+        content: "继续",
+        timestamp: Date.now(),
+      } as any]);
+
+      const loaded = await loadBookSession(tempDir, session.sessionId);
+      expect(loaded?.updatedAt).toBeGreaterThan(session.updatedAt);
+    });
+
     it("lists null bookId sessions", async () => {
     it("lists null bookId sessions", async () => {
       const s = createBookSession(null);
       const s = createBookSession(null);
       await persistBookSession(tempDir, s);
       await persistBookSession(tempDir, s);
@@ -146,6 +265,19 @@ describe("book-session-store", () => {
       const result = await renameBookSession(tempDir, "nonexistent", "title");
       const result = await renameBookSession(tempDir, "nonexistent", "title");
       expect(result).toBeNull();
       expect(result).toBeNull();
     });
     });
+
+    it("assigns unique seq for concurrent metadata updates", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "metadata-race");
+
+      await Promise.all([
+        renameBookSession(tempDir, "metadata-race", "标题 A"),
+        renameBookSession(tempDir, "metadata-race", "标题 B"),
+      ]);
+
+      const events = await readTranscriptEvents(tempDir, "metadata-race");
+      expect(events.map((event) => event.seq)).toEqual([1, 2, 3]);
+      expect(new Set(events.map((event) => event.seq)).size).toBe(events.length);
+    });
   });
   });
 
 
   describe("deleteBookSession", () => {
   describe("deleteBookSession", () => {

+ 1 - 1
packages/core/src/__tests__/config-loader.test.ts

@@ -280,7 +280,7 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.configSource).toBe("studio");
     expect(config.llm.configSource).toBe("studio");
     expect(config.llm.service).toBe("google");
     expect(config.llm.service).toBe("google");
     expect(config.llm.provider).toBe("openai");
     expect(config.llm.provider).toBe("openai");
-    expect(config.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(config.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
     expect(config.llm.model).toBe("gemini-2.5-flash");
     expect(config.llm.model).toBe("gemini-2.5-flash");
     expect(config.llm.apiKey).toBe("sk-google");
     expect(config.llm.apiKey).toBe("sk-google");
   });
   });

+ 3 - 3
packages/core/src/__tests__/effective-llm-config.test.ts

@@ -50,7 +50,7 @@ describe("resolveEffectiveLLMConfig", () => {
     expect(result.llm.configSource).toBe("studio");
     expect(result.llm.configSource).toBe("studio");
     expect(result.diagnostics.configMode).toBe("studio-project");
     expect(result.diagnostics.configMode).toBe("studio-project");
     expect(result.llm.service).toBe("google");
     expect(result.llm.service).toBe("google");
-    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.apiKey).toBe("sk-google");
     expect(result.llm.apiKey).toBe("sk-google");
     expect(result.diagnostics.apiKeySource).toBe("studio-secret");
     expect(result.diagnostics.apiKeySource).toBe("studio-secret");
@@ -226,7 +226,7 @@ describe("resolveEffectiveLLMConfig", () => {
     expect(result.diagnostics.configMode).toBe("legacy-env");
     expect(result.diagnostics.configMode).toBe("legacy-env");
     expect(result.llm.service).toBe("google");
     expect(result.llm.service).toBe("google");
     expect(result.llm.provider).toBe("openai");
     expect(result.llm.provider).toBe("openai");
-    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
     expect(result.llm.apiFormat).toBe("chat");
     expect(result.llm.apiFormat).toBe("chat");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.apiKey).toBe("sk-google");
     expect(result.llm.apiKey).toBe("sk-google");
@@ -346,7 +346,7 @@ describe("resolveEffectiveLLMConfig", () => {
 
 
     expect(result.llm.service).toBe("google");
     expect(result.llm.service).toBe("google");
     expect(result.llm.provider).toBe("openai");
     expect(result.llm.provider).toBe("openai");
-    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.model).toBe("gemini-2.5-flash");
     expect(result.llm.apiKey).toBe("sk-google");
     expect(result.llm.apiKey).toBe("sk-google");
     expect(result.diagnostics.serviceSource).toBe("cli");
     expect(result.diagnostics.serviceSource).toBe("cli");

+ 5 - 4
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -33,6 +33,7 @@ const hasNodeSqlite = (() => {
 })();
 })();
 
 
 const sqliteIt = hasNodeSqlite ? it : it.skip;
 const sqliteIt = hasNodeSqlite ? it : it.skip;
+const SLOW_PIPELINE_TEST_TIMEOUT_MS = 15_000;
 
 
 const ZERO_USAGE = {
 const ZERO_USAGE = {
   promptTokens: 0,
   promptTokens: 0,
@@ -575,7 +576,7 @@ describe("PipelineRunner", () => {
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
-  }, 15_000);
+  }, SLOW_PIPELINE_TEST_TIMEOUT_MS);
 
 
   it("cleans staged files when initBook fails before foundation is complete", async () => {
   it("cleans staged files when initBook fails before foundation is complete", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-init-rollback-"));
     const root = await mkdtemp(join(tmpdir(), "inkos-init-rollback-"));
@@ -4508,7 +4509,7 @@ describe("PipelineRunner", () => {
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
-  });
+  }, SLOW_PIPELINE_TEST_TIMEOUT_MS);
 
 
   it("persists manual revisions only when merged audit improves", async () => {
   it("persists manual revisions only when merged audit improves", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
@@ -4589,7 +4590,7 @@ describe("PipelineRunner", () => {
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
-  });
+  }, SLOW_PIPELINE_TEST_TIMEOUT_MS);
 
 
   it("re-audits revisions against updated state overrides instead of stale on-disk truth files", async () => {
   it("re-audits revisions against updated state overrides instead of stale on-disk truth files", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();
@@ -4705,7 +4706,7 @@ describe("PipelineRunner", () => {
     } finally {
     } finally {
       await rm(root, { recursive: true, force: true });
       await rm(root, { recursive: true, force: true });
     }
     }
-  });
+  }, SLOW_PIPELINE_TEST_TIMEOUT_MS);
 
 
   it("excludes pure sequence-level fatigue from revision blocker counts", async () => {
   it("excludes pure sequence-level fatigue from revision blocker counts", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture();
     const { root, runner, state, bookId } = await createRunnerFixture();

+ 18 - 0
packages/core/src/__tests__/planner.test.ts

@@ -143,6 +143,24 @@ describe("PlannerAgent.planChapter memo generation", () => {
     expect(result.memo.body).toContain("## 当前任务");
     expect(result.memo.body).toContain("## 当前任务");
   });
   });
 
 
+  it("does not hard-cap memo generation below the configured model output budget", async () => {
+    const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
+      content: validMemoRaw(1),
+      usage: ZERO_USAGE,
+    } as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
+
+    await makePlanner().planChapter({
+      book: makeBook(),
+      bookDir,
+      chapterNumber: 1,
+    });
+
+    const callArgs = chatSpy.mock.calls[0]!;
+    const options = callArgs[3] as { temperature?: number; maxTokens?: number } | undefined;
+    expect(options).toEqual(expect.objectContaining({ temperature: 0.7 }));
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+
   it("retries when the first response is malformed and succeeds on retry", async () => {
   it("retries when the first response is malformed and succeeds on retry", async () => {
     const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
     const chatSpy = vi.spyOn(llmProvider, "chatCompletion")
       .mockResolvedValueOnce({
       .mockResolvedValueOnce({

+ 15 - 7
packages/core/src/__tests__/provider.test.ts

@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
 import {
 import {
   __resetFixedTemperatureWarnings,
   __resetFixedTemperatureWarnings,
@@ -403,6 +403,10 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
     mockStreamSimple.mockReturnValue(makeTextStream("ok"));
   });
   });
 
 
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
   it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
     const client = makeClient(0.7);
     const client = makeClient(0.7);
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
     const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -512,7 +516,7 @@ describe("createLLMClient per-call maxTokens not capped (v2.0.0)", () => {
 });
 });
 
 
 describe("createLLMClient with providers lookup", () => {
 describe("createLLMClient with providers lookup", () => {
-  it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是 8192 fallback", async () => {
+  it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是未知模型兜底", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
     const client = createLLMClient(LLMConfigSchema.parse({
@@ -541,7 +545,7 @@ describe("createLLMClient with providers lookup", () => {
     expect(client.defaults.maxTokens).toBe(4096);
     expect(client.defaults.maxTokens).toBe(4096);
   });
   });
 
 
-  it("未知 model 走保守默认 8192", async () => {
+  it("未知 model 走 8192 * 3 的写作兜底预算", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
     const client = createLLMClient(LLMConfigSchema.parse({
@@ -551,7 +555,8 @@ describe("createLLMClient with providers lookup", () => {
       apiKey: "test",
       apiKey: "test",
       baseUrl: "https://middleman.example/v1",
       baseUrl: "https://middleman.example/v1",
     }));
     }));
-    expect(client.defaults.maxTokens).toBe(8192);
+    expect(client.defaults.maxTokens).toBe(24_576);
+    expect(client._piModel?.maxTokens).toBe(24_576);
   });
   });
 
 
   it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {
   it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {
@@ -594,7 +599,7 @@ describe("createLLMClient with providers lookup", () => {
     expect(client._piModel?.id).toBe("kimi-k2-thinking");
     expect(client._piModel?.id).toBe("kimi-k2-thinking");
   });
   });
 
 
-  it("Google Gemini OpenAI-compatible endpoint disables OpenAI store parameter", async () => {
+  it("Google Gemini uses native google-generative-ai provider", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
     const client = createLLMClient(LLMConfigSchema.parse({
@@ -602,8 +607,11 @@ describe("createLLMClient with providers lookup", () => {
       service: "google",
       service: "google",
       model: "gemini-2.5-flash",
       model: "gemini-2.5-flash",
       apiKey: "test",
       apiKey: "test",
-      baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta",
     }));
     }));
-    expect(client._piModel?.compat).toMatchObject({ supportsStore: false });
+    expect(client._piModel?.api).toBe("google-generative-ai");
+    expect(client._piModel?.provider).toBe("google");
+    expect(client._piModel?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
+    expect(client._piModel?.compat).toBeUndefined();
   });
   });
 });
 });

+ 1 - 1
packages/core/src/__tests__/providers-schema.test.ts

@@ -7,7 +7,7 @@ describe("providers structural integrity", () => {
     for (const p of getAllEndpoints()) {
     for (const p of getAllEndpoints()) {
       expect(p.id).toBeTruthy();
       expect(p.id).toBeTruthy();
       expect(p.label).toBeTruthy();
       expect(p.label).toBeTruthy();
-      expect(p.api).toMatch(/^(openai-completions|openai-responses|anthropic-messages)$/);
+      expect(p.api).toMatch(/^(openai-completions|openai-responses|anthropic-messages|google-generative-ai)$/);
       // gateway/anchor provider 允许 baseUrl 为空(由用户填)
       // gateway/anchor provider 允许 baseUrl 为空(由用户填)
       if (gatewayProviders.has(p.id)) {
       if (gatewayProviders.has(p.id)) {
         expect(typeof p.baseUrl).toBe("string");
         expect(typeof p.baseUrl).toBe("string");

+ 27 - 0
packages/core/src/__tests__/service-resolver.test.ts

@@ -84,6 +84,33 @@ describe("resolveServiceModel", () => {
     expect(result.temperatureRange).toEqual([0, 2]);
     expect(result.temperatureRange).toEqual([0, 2]);
   });
   });
 
 
+  it("resolves Google to native google-generative-ai model", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { google: { apiKey: "sk-google" } } }),
+    );
+
+    const result = await resolveServiceModel("google", "gemini-pro-latest", root);
+
+    expect(result.model.api).toBe("google-generative-ai");
+    expect(result.model.provider).toBe("google");
+    expect(result.model.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta");
+    expect(result.model.compat).toBeUndefined();
+  });
+
+  it("preserves DeepSeek tool-result bridge compatibility on resolved model", async () => {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
+    );
+
+    const result = await resolveServiceModel("deepseek", "deepseek-v4-pro", root);
+
+    expect(result.model.compat).toMatchObject({ requiresAssistantAfterToolResult: true });
+  });
+
   it("constructs model from preset when getModel returns undefined", async () => {
   it("constructs model from preset when getModel returns undefined", async () => {
     await mkdir(join(root, ".inkos"), { recursive: true });
     await mkdir(join(root, ".inkos"), { recursive: true });
     await writeFile(
     await writeFile(

+ 1309 - 0
packages/core/src/__tests__/session-transcript-restore.test.ts

@@ -0,0 +1,1309 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { appendTranscriptEvent } from "../interaction/session-transcript.js";
+import {
+  adaptRestoredAgentMessagesForModel,
+  deriveBookSessionFromTranscript,
+  restoreAgentMessagesFromTranscript,
+  TOOL_RESULT_BRIDGE_TEXT,
+} from "../interaction/session-transcript-restore.js";
+import type { MessageEvent } from "../interaction/session-transcript-schema.js";
+
+const usage = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+describe("session transcript restore", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-restore-"));
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("只恢复已 committed request 内的 message", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "hi",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 2,
+      role: "user",
+      timestamp: 2,
+      message: { role: "user", content: "hi", timestamp: 2 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 3,
+      timestamp: 3,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 4,
+      timestamp: 4,
+      input: "lost",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "u2",
+      parentUuid: "u1",
+      seq: 5,
+      role: "user",
+      timestamp: 5,
+      message: { role: "user", content: "lost", timestamp: 5 },
+    } as MessageEvent);
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+
+    expect(restored).toHaveLength(1);
+    expect(restored[0]).toMatchObject({ role: "user", content: "hi" });
+  });
+
+  it("保留 committed toolResult 和 assistant thinking signature", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "tool",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: null,
+      seq: 2,
+      role: "assistant",
+      timestamp: 2,
+      toolCallId: "tool-1",
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "需要查资料", signature: "sig" },
+          { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "tool_use",
+        timestamp: 2,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 3,
+      role: "toolResult",
+      timestamp: 3,
+      toolCallId: "tool-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        details: { path: "a.md" },
+        isError: false,
+        timestamp: 3,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 4,
+      timestamp: 4,
+    });
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+
+    expect(restored).toHaveLength(2);
+    expect(restored[0]).toMatchObject({
+      role: "assistant",
+      content: [
+        { type: "thinking", thinking: "需要查资料", signature: "sig" },
+        { type: "toolCall", id: "tool-1" },
+      ],
+    });
+    expect(restored[1]).toMatchObject({ role: "toolResult", toolCallId: "tool-1", toolName: "read" });
+    expect(JSON.stringify(restored)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
+  it("恢复中断工具轮次时只清理空 assistant,不在 raw restore 阶段补 bridge", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "tool",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 2,
+      role: "user",
+      timestamp: 2,
+      message: { role: "user", content: "tool", timestamp: 2 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 3,
+      role: "assistant",
+      timestamp: 3,
+      toolCallId: "tool-1",
+      message: {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "a.md" } }],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 3,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 4,
+      role: "toolResult",
+      timestamp: 4,
+      toolCallId: "tool-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a2",
+      parentUuid: "t1",
+      seq: 5,
+      role: "assistant",
+      timestamp: 5,
+      message: {
+        role: "assistant",
+        content: [],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "error",
+        errorMessage: "400 status code",
+        timestamp: 5,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 6,
+      timestamp: 6,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 7,
+      timestamp: 7,
+      input: "继续",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "u2",
+      parentUuid: "a2",
+      seq: 8,
+      role: "user",
+      timestamp: 8,
+      message: { role: "user", content: "继续", timestamp: 8 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 9,
+      timestamp: 9,
+    });
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+
+    expect(restored.map((message) => message.role)).toEqual([
+      "user",
+      "assistant",
+      "toolResult",
+      "user",
+    ]);
+    expect(JSON.stringify(restored)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
+  it("移除最后 assistant message 的 trailing thinking block", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 1,
+      input: "hi",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: null,
+      seq: 2,
+      role: "assistant",
+      timestamp: 2,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "text", text: "回答" },
+          { type: "thinking", thinking: "尾部", signature: "sig" },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 2,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 3,
+      timestamp: 3,
+    });
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+
+    expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
+  });
+
+  it("跨模型恢复时移除 provider-specific thinking,但保留正文", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "DeepSeek reasoning", thinkingSignature: "reasoning_content" },
+          { type: "text", text: "可见回答" },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "stop",
+        timestamp: 1,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "gemini-pro-latest",
+    });
+
+    expect((adapted[0] as any).content).toEqual([{ type: "text", text: "可见回答" }]);
+  });
+
+  it("同模型恢复时保留 thinking continuity", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "DeepSeek reasoning", thinkingSignature: "reasoning_content" },
+          { type: "text", text: "可见回答" },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "stop",
+        timestamp: 1,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+    });
+
+    expect((adapted[0] as any).content).toEqual(messages[0].content);
+  });
+
+  it("同模型恢复时保留 reasoning_content 和原生 toolResult 连续性", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "Need a file", thinkingSignature: "reasoning_content" },
+          { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "story.md" } },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+    });
+
+    expect(adapted).toEqual(messages);
+  });
+
+  it("does not add synthetic toolResult bridge when target model does not require it", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: {} }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "result" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any[];
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "anthropic-messages",
+      provider: "anthropic",
+      id: "claude",
+    });
+
+    expect(JSON.stringify(adapted)).not.toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
+  it("adds synthetic toolResult bridge when target model compat requires it", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: {} }],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "result" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any[];
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+      compat: { requiresAssistantAfterToolResult: true },
+    });
+
+    expect(JSON.stringify(adapted)).toContain(TOOL_RESULT_BRIDGE_TEXT);
+  });
+
+  it("跨模型恢复时把原生工具回合降级为 user 文本", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "story.md" } },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "资料" }],
+        isError: false,
+        timestamp: 2,
+      },
+      {
+        role: "assistant",
+        content: [{ type: "text", text: "I have processed the tool results." }],
+        api: "openai-completions",
+        provider: "inkos",
+        model: "synthetic-tool-result-bridge",
+        usage,
+        stopReason: "stop",
+        timestamp: 3,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "openai-completions",
+      provider: "openai",
+      id: "deepseek-v4-pro",
+    });
+
+    expect(JSON.stringify(adapted)).not.toContain("\"toolCall\"");
+    expect(adapted.some((message: any) => message.role === "toolResult")).toBe(false);
+    expect(adapted).toEqual([
+      expect.objectContaining({
+        role: "user",
+        content: expect.stringContaining("[Tool results]"),
+      }),
+    ]);
+    expect(JSON.stringify(adapted)).toContain("read");
+    expect(JSON.stringify(adapted)).toContain("tool-1");
+    expect(JSON.stringify(adapted)).toContain("资料");
+    expect(JSON.stringify(adapted)).not.toContain("I have processed the tool results.");
+  });
+
+  it("native Google 同协议恢复时保留 thinking signature 和原生工具回合", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "plan", thinkingSignature: "google-signature" },
+          { type: "toolCall", id: "tool-1", name: "ls", arguments: { subdir: "story/roles" } },
+        ],
+        api: "google-generative-ai",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "ls",
+        content: [{ type: "text", text: "主要角色/\n次要角色/" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "google-generative-ai",
+      provider: "google",
+      id: "gemini-pro-latest",
+    });
+
+    expect(adapted).toEqual(messages);
+    expect(JSON.stringify(adapted)).toContain("google-signature");
+  });
+
+  it("切到 native Google 时把旧 OpenAI-compatible Gemini 工具回合文本化", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [{ type: "toolCall", id: "tool-1", name: "ls", arguments: { subdir: "story/roles" } }],
+        api: "openai-completions",
+        provider: "openai",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "ls",
+        content: [{ type: "text", text: "主要角色/" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "google-generative-ai",
+      provider: "google",
+      id: "gemini-pro-latest",
+    });
+
+    const body = JSON.stringify(adapted);
+    expect(body).not.toContain("\"toolCall\"");
+    expect(adapted.some((message: any) => message.role === "toolResult")).toBe(false);
+    expect(adapted).toEqual([
+      expect.objectContaining({
+        role: "user",
+        content: expect.stringContaining("[Tool results]"),
+      }),
+    ]);
+    expect(body).toContain("ls");
+    expect(body).toContain("主要角色");
+  });
+
+  it("切到 native Google 时丢弃 DeepSeek reasoning_content 并文本化工具结果", () => {
+    const messages = [
+      {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "deepseek reasoning", thinkingSignature: "reasoning_content" },
+          { type: "text", text: "先看角色。" },
+          { type: "toolCall", id: "tool-1", name: "read", arguments: { path: "story/roles/林默.md" } },
+        ],
+        api: "openai-completions",
+        provider: "openai",
+        model: "deepseek-v4-pro",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 1,
+      },
+      {
+        role: "toolResult",
+        toolCallId: "tool-1",
+        toolName: "read",
+        content: [{ type: "text", text: "林默资料" }],
+        isError: false,
+        timestamp: 2,
+      },
+    ] as any;
+
+    const adapted = adaptRestoredAgentMessagesForModel(messages, {
+      api: "google-generative-ai",
+      provider: "google",
+      id: "gemini-pro-latest",
+    });
+
+    const body = JSON.stringify(adapted);
+    expect(body).not.toContain("reasoning_content");
+    expect(body).not.toContain("deepseek reasoning");
+    expect(body).not.toContain("\"toolCall\"");
+    expect(body).toContain("先看角色。");
+    expect(body).toContain("[Tool results]");
+    expect(body).toContain("林默资料");
+  });
+
+  it("派生 BookSession 时跳过没有正文的 assistant tool-use message", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: null,
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "你好",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "你好", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "内部推理" },
+          { type: "toolCall", id: "read-1", name: "read", arguments: { path: "books/a.md" } },
+        ],
+        api: "openai-completions",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session?.messages).toEqual([{ role: "user", content: "你好", timestamp: 3 }]);
+  });
+
+  it("从 transcript 派生 BookSession UI 视图", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "第一条问题",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "第一条问题", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "思考" },
+          { type: "text", text: "回答" },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session).toMatchObject({
+      sessionId: "s1",
+      bookId: "book-a",
+      title: "第一条问题",
+      messages: [
+        { role: "user", content: "第一条问题" },
+        { role: "assistant", content: "回答", thinking: "思考" },
+      ],
+    });
+  });
+
+  it("从 transcript 派生 BookSession UI 工具执行记录", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "查看角色目录",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "查看角色目录", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      toolCallId: "ls-1",
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "先列目录" },
+          { type: "toolCall", id: "ls-1", name: "ls", arguments: { bookId: "book-a", subdir: "story/roles" } },
+        ],
+        api: "google-generative-ai",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 5,
+      role: "toolResult",
+      timestamp: 5,
+      toolCallId: "ls-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "ls-1",
+        toolName: "ls",
+        content: [{ type: "text", text: "主要角色/\n次要角色/" }],
+        isError: false,
+        timestamp: 5,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a2",
+      parentUuid: "t1",
+      seq: 6,
+      role: "assistant",
+      timestamp: 6,
+      message: {
+        role: "assistant",
+        content: [{ type: "text", text: "角色目录已查看。" }],
+        api: "google-generative-ai",
+        provider: "google",
+        model: "gemini-pro-latest",
+        usage,
+        stopReason: "stop",
+        timestamp: 6,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 7,
+      timestamp: 7,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session?.messages).toMatchObject([
+      { role: "user", content: "查看角色目录" },
+      {
+        role: "assistant",
+        content: "角色目录已查看。",
+        thinking: "先列目录",
+        toolExecutions: [{
+          id: "ls-1",
+          tool: "ls",
+          label: "列目录",
+          status: "completed",
+          args: { bookId: "book-a", subdir: "story/roles" },
+          result: "主要角色/\n次要角色/",
+          startedAt: 4,
+          completedAt: 5,
+        }],
+      },
+    ]);
+  });
+
+  it("keeps UI message order by transcript seq instead of message timestamp", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "先问",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 100,
+      message: { role: "user", content: "先问", timestamp: 100 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 50,
+      message: {
+        role: "assistant",
+        content: [{ type: "text", text: "后答" }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 50,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session?.messages.map((message) => message.content)).toEqual(["先问", "后答"]);
+  });
+
+  it("does not carry pending tool executions or thinking across request boundaries", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "列目录",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "列目录", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      toolCallId: "ls-1",
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "第一轮工具前思考" },
+          { type: "toolCall", id: "ls-1", name: "ls", arguments: { subdir: "story" } },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "t1",
+      parentUuid: "a1",
+      seq: 5,
+      role: "toolResult",
+      timestamp: 5,
+      toolCallId: "ls-1",
+      sourceToolAssistantUuid: "a1",
+      message: {
+        role: "toolResult",
+        toolCallId: "ls-1",
+        toolName: "ls",
+        content: [{ type: "text", text: "roles/" }],
+        isError: false,
+        timestamp: 5,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 6,
+      timestamp: 6,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 7,
+      timestamp: 7,
+      input: "继续",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "u2",
+      parentUuid: null,
+      seq: 8,
+      role: "user",
+      timestamp: 8,
+      message: { role: "user", content: "继续", timestamp: 8 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "a2",
+      parentUuid: "u2",
+      seq: 9,
+      role: "assistant",
+      timestamp: 9,
+      message: {
+        role: "assistant",
+        content: [{ type: "text", text: "第二轮回答" }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 9,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 10,
+      timestamp: 10,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+    const secondAssistant = session?.messages.find((message) => message.content === "第二轮回答");
+
+    expect(secondAssistant).toMatchObject({ role: "assistant", content: "第二轮回答" });
+    expect(secondAssistant).not.toHaveProperty("thinking");
+    expect(secondAssistant).not.toHaveProperty("toolExecutions");
+  });
+
+  it("does not resolve tool results with tool calls from a previous request", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "第一轮",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: null,
+      seq: 3,
+      role: "assistant",
+      timestamp: 3,
+      toolCallId: "shared-tool",
+      message: {
+        role: "assistant",
+        content: [
+          { type: "toolCall", id: "shared-tool", name: "ls", arguments: { subdir: "story/roles" } },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "toolUse",
+        timestamp: 3,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 4,
+      timestamp: 4,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 5,
+      timestamp: 5,
+      input: "第二轮",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "u2",
+      parentUuid: null,
+      seq: 6,
+      role: "user",
+      timestamp: 6,
+      message: { role: "user", content: "第二轮", timestamp: 6 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "t2",
+      parentUuid: "u2",
+      seq: 7,
+      role: "toolResult",
+      timestamp: 7,
+      toolCallId: "shared-tool",
+      message: {
+        role: "toolResult",
+        toolCallId: "shared-tool",
+        toolName: "ls",
+        content: [{ type: "text", text: "chapters/" }],
+        isError: false,
+        timestamp: 7,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      uuid: "a2",
+      parentUuid: "t2",
+      seq: 8,
+      role: "assistant",
+      timestamp: 8,
+      message: {
+        role: "assistant",
+        content: [{ type: "text", text: "第二轮回答" }],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 8,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r2",
+      seq: 9,
+      timestamp: 9,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+    const secondAssistant = session?.messages.find((message) => message.content === "第二轮回答");
+
+    expect(secondAssistant?.toolExecutions).toEqual([
+      expect.objectContaining({
+        id: "shared-tool",
+        tool: "ls",
+        result: "chapters/",
+        startedAt: 7,
+        completedAt: 7,
+      }),
+    ]);
+    expect(secondAssistant?.toolExecutions?.[0]).not.toHaveProperty("args");
+  });
+});

+ 195 - 0
packages/core/src/__tests__/session-transcript.test.ts

@@ -0,0 +1,195 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  appendManualSessionMessages,
+  appendTranscriptEvents,
+  appendTranscriptEvent,
+  nextTranscriptSeq,
+  readTranscriptEvents,
+  transcriptPath,
+} from "../interaction/session-transcript.js";
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
+import type {
+  MessageEvent,
+  RequestCommittedEvent,
+  RequestStartedEvent,
+} from "../interaction/session-transcript-schema.js";
+
+describe("session transcript codec", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-transcript-"));
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("一行写入一个 JSON event 并保留 raw AgentMessage 字段", async () => {
+    const started: RequestStartedEvent = {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 100,
+      input: "继续写",
+    };
+    const message: MessageEvent = {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "m1",
+      parentUuid: null,
+      seq: 2,
+      role: "assistant",
+      timestamp: 101,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "推理", signature: "sig-1" },
+          { type: "text", text: "正文" },
+        ],
+        provider: "anthropic",
+        api: "anthropic-messages",
+        model: "claude",
+        usage: {
+          input: 1,
+          output: 2,
+          cacheRead: 0,
+          cacheWrite: 0,
+          totalTokens: 3,
+          cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+        },
+        stopReason: "stop",
+        timestamp: 101,
+      },
+    };
+
+    await appendTranscriptEvent(projectRoot, started);
+    await appendTranscriptEvent(projectRoot, message);
+
+    const raw = await readFile(transcriptPath(projectRoot, "s1"), "utf-8");
+    expect(raw.trim().split("\n")).toHaveLength(2);
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events).toHaveLength(2);
+    expect((events[1] as MessageEvent).message).toMatchObject({
+      role: "assistant",
+      content: [
+        { type: "thinking", thinking: "推理", signature: "sig-1" },
+        { type: "text", text: "正文" },
+      ],
+    });
+  });
+
+  it("跳过坏行并保留合法 event", async () => {
+    const dir = join(projectRoot, ".inkos", "sessions");
+    await mkdir(dir, { recursive: true });
+    await writeFile(
+      join(dir, "s1.jsonl"),
+      [
+        JSON.stringify({
+          type: "request_started",
+          version: 1,
+          sessionId: "s1",
+          requestId: "r1",
+          seq: 1,
+          timestamp: 1,
+          input: "hi",
+        }),
+        "{bad json",
+        JSON.stringify({
+          type: "request_committed",
+          version: 1,
+          sessionId: "s1",
+          requestId: "r1",
+          seq: 2,
+          timestamp: 2,
+        }),
+      ].join("\n"),
+    );
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events.map((event) => event.type)).toEqual(["request_started", "request_committed"]);
+  });
+
+  it("按已有 transcript 分配单调递增 seq", async () => {
+    const committed: RequestCommittedEvent = {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 7,
+      timestamp: 100,
+    };
+
+    await appendTranscriptEvent(projectRoot, committed);
+
+    await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
+  });
+
+  it("atomically assigns unique seq for concurrent generated events", async () => {
+    await Promise.all(
+      Array.from({ length: 10 }, (_, index) =>
+        appendTranscriptEvents(projectRoot, "s-concurrent", ({ nextSeq }) => [{
+          type: "request_started",
+          version: 1,
+          sessionId: "s-concurrent",
+          requestId: `r-${index}`,
+          seq: nextSeq,
+          timestamp: 100 + index,
+          input: `input-${index}`,
+        }]),
+      ),
+    );
+
+    const events = await readTranscriptEvents(projectRoot, "s-concurrent");
+    expect(events).toHaveLength(10);
+    expect(events.map((event) => event.seq)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+  });
+
+  it("appendManualSessionMessages 写入 committed request 并保留 raw assistant message", async () => {
+    await appendManualSessionMessages(projectRoot, "s1", [{
+      role: "assistant",
+      content: [{ type: "text", text: "fallback" }],
+      api: "anthropic-messages",
+      provider: "anthropic",
+      model: "fake",
+      usage: {
+        input: 0,
+        output: 0,
+        cacheRead: 0,
+        cacheWrite: 0,
+        totalTokens: 0,
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+      },
+      stopReason: "stop",
+      timestamp: 10,
+    }], "fallback-input");
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events.map((event) => event.type)).toEqual([
+      "request_started",
+      "message",
+      "request_committed",
+    ]);
+    expect(events[0]).toMatchObject({ type: "request_started", input: "fallback-input" });
+
+    const restored = await restoreAgentMessagesFromTranscript(projectRoot, "s1");
+    expect(restored).toMatchObject([
+      { role: "assistant", content: [{ type: "text", text: "fallback" }] },
+    ]);
+  });
+
+  it("从 core index 导出 transcript helper", async () => {
+    const core = await import("../index.js");
+    expect(typeof core.readTranscriptEvents).toBe("function");
+    expect(typeof core.restoreAgentMessagesFromTranscript).toBe("function");
+    expect(typeof core.TranscriptEventSchema.safeParse).toBe("function");
+  });
+});

+ 451 - 64
packages/core/src/agent/agent-session.ts

@@ -1,7 +1,8 @@
+import { randomUUID } from "node:crypto";
 import { Agent } from "@mariozechner/pi-agent-core";
 import { Agent } from "@mariozechner/pi-agent-core";
 import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
 import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
 import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
 import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
-import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
+import type { Model, Api, AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
 import {
 import {
@@ -16,6 +17,16 @@ import {
   createWriteTruthFileTool,
   createWriteTruthFileTool,
 } from "./agent-tools.js";
 } from "./agent-tools.js";
 import { createBookContextTransform } from "./context-transform.js";
 import { createBookContextTransform } from "./context-transform.js";
+import {
+  appendTranscriptEvents,
+  readTranscriptEvents,
+} from "../interaction/session-transcript.js";
+import {
+  TOOL_RESULT_BRIDGE_TEXT,
+  adaptRestoredAgentMessagesForModel,
+  restoreAgentMessagesFromTranscript,
+} from "../interaction/session-transcript-restore.js";
+import type { TranscriptEvent, TranscriptRole } from "../interaction/session-transcript-schema.js";
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Types
 // Types
@@ -36,6 +47,8 @@ export interface AgentSessionConfig {
   model: Model<Api> | { provider: string; modelId: string };
   model: Model<Api> | { provider: string; modelId: string };
   /** Optional API key. When omitted, falls back to env-based key lookup. */
   /** Optional API key. When omitted, falls back to env-based key lookup. */
   apiKey?: string;
   apiKey?: string;
+  /** Allow the read tool to read absolute paths outside projectRoot/books. Defaults to true; set INKOS_AGENT_ALLOW_SYSTEM_READ=0 to disable. */
+  allowSystemFileRead?: boolean;
   /** Optional listener for streaming events (for SSE forwarding). */
   /** Optional listener for streaming events (for SSE forwarding). */
   onEvent?: (event: AgentEvent) => void;
   onEvent?: (event: AgentEvent) => void;
 }
 }
@@ -43,26 +56,31 @@ export interface AgentSessionConfig {
 export interface AgentSessionResult {
 export interface AgentSessionResult {
   /** Extracted text from the final assistant message. */
   /** Extracted text from the final assistant message. */
   responseText: string;
   responseText: string;
-  /** Full conversation history for persistence. */
-  messages: Array<{ role: string; content: string; thinking?: string }>;
+  /** Full raw Agent conversation history. */
+  messages: AgentMessage[];
+  /** Upstream model error surfaced by pi-agent-core, if the final assistant turn failed. */
+  errorMessage?: string;
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // Cache
 // Cache
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-// We only record fields that can realistically change between turns on the
-// same sessionId and are captured into the Agent at construction time.
-// `projectRoot`, `language`, and `pipeline` are also closure-captured by the
-// Agent (into systemPrompt / tools / transformContext), but within a single
-// server process they're treated as stable — we don't re-check them.
 interface CachedAgent {
 interface CachedAgent {
   agent: Agent;
   agent: Agent;
+  sessionId: string;
+  projectRoot: string;
   bookId: string | null;
   bookId: string | null;
+  language: string;
+  modelIdentity: string;
+  apiKey: string | undefined;
+  allowSystemFileRead: boolean;
+  lastCommittedSeq: number;
   lastActive: number;
   lastActive: number;
 }
 }
 
 
 const agentCache = new Map<string, CachedAgent>();
 const agentCache = new Map<string, CachedAgent>();
+const agentSessionQueues = new Map<string, Promise<void>>();
 
 
 /** TTL for cached agents: 5 minutes. */
 /** TTL for cached agents: 5 minutes. */
 const CACHE_TTL_MS = 5 * 60 * 1000;
 const CACHE_TTL_MS = 5 * 60 * 1000;
@@ -110,6 +128,139 @@ function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
   return getModel(provider as any, modelId as any);
   return getModel(provider as any, modelId as any);
 }
 }
 
 
+function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
+  if (value === undefined) return defaultValue;
+  if (value === "1" || value.toLowerCase() === "true") return true;
+  if (value === "0" || value.toLowerCase() === "false") return false;
+  return defaultValue;
+}
+
+function agentModelIdentity(model: Model<Api>): string {
+  return [
+    model.api,
+    model.provider,
+    model.baseUrl ?? "",
+    model.id,
+  ].join("::");
+}
+
+function sessionQueueKey(projectRoot: string, sessionId: string): string {
+  return `${projectRoot}\0${sessionId}`;
+}
+
+function agentCacheKey(projectRoot: string, sessionId: string): string {
+  return sessionQueueKey(projectRoot, sessionId);
+}
+
+async function runInAgentSessionQueue<T>(
+  projectRoot: string,
+  sessionId: string,
+  task: () => Promise<T>,
+): Promise<T> {
+  const key = sessionQueueKey(projectRoot, sessionId);
+  const previous = agentSessionQueues.get(key) ?? Promise.resolve();
+  let release!: () => void;
+  const gate = new Promise<void>((resolve) => {
+    release = resolve;
+  });
+  const queued = previous.catch(() => undefined).then(() => gate);
+  agentSessionQueues.set(key, queued);
+
+  await previous.catch(() => undefined);
+  try {
+    return await task();
+  } finally {
+    release();
+    if (agentSessionQueues.get(key) === queued) {
+      agentSessionQueues.delete(key);
+    }
+  }
+}
+
+async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  return events
+    .filter((event) => event.type === "request_committed")
+    .reduce((max, event) => Math.max(max, event.seq), 0);
+}
+
+function transcriptRoleForMessage(message: AgentMessage): TranscriptRole | null {
+  if (!message || typeof message !== "object" || !("role" in message)) return null;
+  const role = (message as { role?: unknown }).role;
+  return role === "user" || role === "assistant" || role === "toolResult" || role === "system"
+    ? role
+    : null;
+}
+
+function firstToolCallId(message: AgentMessage): string | undefined {
+  if (!message || typeof message !== "object" || !("content" in message)) return undefined;
+  const content = (message as { content?: unknown }).content;
+  if (!Array.isArray(content)) return undefined;
+  const block = content.find(
+    (item): item is { type: "toolCall"; id: string } =>
+      !!item &&
+      typeof item === "object" &&
+      (item as { type?: unknown }).type === "toolCall" &&
+      typeof (item as { id?: unknown }).id === "string",
+  );
+  return block?.id;
+}
+
+function toolCallIdForMessage(message: AgentMessage): string | undefined {
+  if (!message || typeof message !== "object") return undefined;
+  if ((message as { role?: unknown }).role === "toolResult") {
+    const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
+    return typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
+  }
+  return firstToolCallId(message);
+}
+
+function messageTimestamp(message: AgentMessage): number {
+  if (message && typeof message === "object") {
+    const timestamp = (message as { timestamp?: unknown }).timestamp;
+    if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp >= 0) {
+      return Math.floor(timestamp);
+    }
+  }
+  return Date.now();
+}
+
+async function ensureSessionCreatedEvent(
+  projectRoot: string,
+  sessionId: string,
+  bookId: string | null,
+): Promise<void> {
+  await appendTranscriptEvents(projectRoot, sessionId, ({ events, nextSeq }) => {
+    if (events.some((event) => event.type === "session_created")) return [];
+
+    const now = Date.now();
+    return [{
+      type: "session_created",
+      version: 1,
+      sessionId,
+      seq: nextSeq,
+      timestamp: now,
+      bookId,
+      title: null,
+      createdAt: now,
+      updatedAt: now,
+    }];
+  });
+}
+
+async function appendAgentTranscriptEvent(
+  projectRoot: string,
+  sessionId: string,
+  buildEvent: (seq: number) => TranscriptEvent,
+): Promise<TranscriptEvent> {
+  const events = await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => [
+    buildEvent(nextSeq),
+  ]);
+  const event = events[0];
+  if (!event) throw new Error(`Failed to append transcript event for session "${sessionId}"`);
+  return event;
+}
+
 /**
 /**
  * Extract readable text from an AssistantMessage's content array.
  * Extract readable text from an AssistantMessage's content array.
  * Filters out tool-call blocks; concatenates text blocks.
  * Filters out tool-call blocks; concatenates text blocks.
@@ -121,6 +272,120 @@ function extractTextFromAssistant(msg: AssistantMessage): string {
     .join("");
     .join("");
 }
 }
 
 
+function lastAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined {
+  for (let i = messages.length - 1; i >= 0; i--) {
+    const msg = messages[i];
+    if (msg && typeof msg === "object" && "role" in msg && (msg as { role?: unknown }).role === "assistant") {
+      return msg as AssistantMessage;
+    }
+  }
+  return undefined;
+}
+
+function assistantErrorMessage(message: AssistantMessage | undefined): string | undefined {
+  return message &&
+    (message.stopReason === "error" || message.stopReason === "aborted") &&
+    message.errorMessage
+      ? message.errorMessage
+      : undefined;
+}
+
+function convertAgentMessagesForModel(messages: AgentMessage[], model: Model<Api>): Message[] {
+  const llmMessages = messages.filter((message): message is Message => {
+    if (!message || typeof message !== "object" || !("role" in message)) return false;
+    return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
+  });
+
+  const candidate = model as { api?: unknown; baseUrl?: unknown };
+  const isGoogleOpenAICompatible = (
+    candidate.api === "openai-completions" &&
+    typeof candidate.baseUrl === "string" &&
+    candidate.baseUrl.includes("generativelanguage.googleapis.com")
+  );
+  if (!isGoogleOpenAICompatible) return llmMessages;
+
+  const converted: Message[] = [];
+  const pushToolResultsAsUser = (toolResults: ToolResultMessage[]) => {
+    const lines = toolResults.flatMap((result) => {
+      const content = result.content
+        .map((block) => block.type === "text" ? block.text : "[image]")
+        .filter(Boolean)
+        .join("\n")
+        .trim() || "(empty tool result)";
+      return [`- ${result.toolName} (${result.toolCallId}):`, content];
+    });
+    converted.push({
+      role: "user",
+      content: [
+        "[Tool results]",
+        ...lines,
+        "Use these tool results to answer the active user request. If a tool failed, explain the failure and choose the next useful action.",
+      ].join("\n"),
+      timestamp: toolResults.reduce(
+        (max, result) => Math.max(max, messageTimestamp(result as AgentMessage)),
+        0,
+      ) || Date.now(),
+    });
+  };
+
+  for (let i = 0; i < llmMessages.length; i++) {
+    const message = llmMessages[i];
+
+    if (message.role === "assistant") {
+      const textContent = message.content.filter(
+        (block): block is { type: "text"; text: string } =>
+          block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0,
+      );
+      if (
+        textContent.length === 1 &&
+        message.content.length === 1 &&
+        textContent[0].text.trim() === TOOL_RESULT_BRIDGE_TEXT
+      ) {
+        continue;
+      }
+
+      const toolCallIds = new Set<string>();
+      for (const block of message.content) {
+        if (block.type === "toolCall" && typeof block.id === "string" && block.id.length > 0) {
+          toolCallIds.add(block.id);
+        }
+      }
+      if (toolCallIds.size === 0) {
+        converted.push(message);
+        continue;
+      }
+
+      if (textContent.length > 0) {
+        converted.push({ ...message, content: textContent });
+      }
+
+      const toolResults: ToolResultMessage[] = [];
+      let nextIndex = i + 1;
+      while (nextIndex < llmMessages.length) {
+        const next = llmMessages[nextIndex];
+        if (next.role !== "toolResult" || !toolCallIds.has(next.toolCallId)) break;
+        toolResults.push(next);
+        nextIndex += 1;
+      }
+
+      if (toolResults.length > 0) {
+        pushToolResultsAsUser(toolResults);
+        i = nextIndex - 1;
+      }
+      continue;
+    }
+
+    if (message.role === "toolResult") {
+      pushToolResultsAsUser([message]);
+      continue;
+    }
+
+    converted.push(message);
+  }
+
+  return converted;
+}
+
 /**
 /**
  * Extract thinking/reasoning text from an AssistantMessage's content array.
  * Extract thinking/reasoning text from an AssistantMessage's content array.
  */
  */
@@ -210,6 +475,16 @@ export async function runAgentSession(
   config: AgentSessionConfig,
   config: AgentSessionConfig,
   userMessage: string,
   userMessage: string,
   initialMessages?: Array<{ role: string; content: string }>,
   initialMessages?: Array<{ role: string; content: string }>,
+): Promise<AgentSessionResult> {
+  return runInAgentSessionQueue(config.projectRoot, config.sessionId, () =>
+    runAgentSessionUnlocked(config, userMessage, initialMessages)
+  );
+}
+
+async function runAgentSessionUnlocked(
+  config: AgentSessionConfig,
+  userMessage: string,
+  initialMessages?: Array<{ role: string; content: string }>,
 ): Promise<AgentSessionResult> {
 ): Promise<AgentSessionResult> {
   const { sessionId, language, pipeline, projectRoot, onEvent } = config;
   const { sessionId, language, pipeline, projectRoot, onEvent } = config;
   // Normalize at the entry point so downstream comparisons, closures, and
   // Normalize at the entry point so downstream comparisons, closures, and
@@ -218,44 +493,60 @@ export async function runAgentSession(
   // skipped) and we don't want that to (a) throw in path.join or (b) trigger
   // skipped) and we don't want that to (a) throw in path.join or (b) trigger
   // a spurious cache eviction because `null !== undefined`.
   // a spurious cache eviction because `null !== undefined`.
   const bookId: string | null = config.bookId ?? null;
   const bookId: string | null = config.bookId ?? null;
+  const model = resolveModel(config.model);
+  const requestedModelIdentity = agentModelIdentity(model);
+  const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, true);
+  const cacheKey = agentCacheKey(projectRoot, sessionId);
 
 
   // ----- Resolve or create Agent -----
   // ----- Resolve or create Agent -----
-  let cached = agentCache.get(sessionId);
+  let cached = agentCache.get(cacheKey);
+  let currentCommittedSeq: number | undefined;
 
 
   if (cached) {
   if (cached) {
-    // Evict and rebuild if model OR bookId changed. Both are captured into the
-    // Agent at construction time (model via initialState, bookId via closures
-    // in systemPrompt / tools / transformContext), so a mismatch means the
-    // cached Agent would keep using stale context — including reading truth
-    // files from the wrong book's story/ directory.
-    const currentModelId = (cached.agent.state.model as any)?.id;
-    const newModelId = typeof config.model === 'object' && 'id' in config.model
-      ? (config.model as any).id
-      : undefined;
-    const modelChanged = !!(currentModelId && newModelId && currentModelId !== newModelId);
+    currentCommittedSeq = await latestCommittedSeq(projectRoot, sessionId);
+    // Evict and rebuild if model protocol identity OR bookId changed. Both are
+    // captured into the Agent at construction time (model via initialState,
+    // bookId via closures in systemPrompt / tools / transformContext), so a
+    // mismatch means the cached Agent would keep using stale context.
+    const modelChanged = cached.modelIdentity !== requestedModelIdentity;
+    const projectRootChanged = cached.projectRoot !== projectRoot;
     const bookChanged = cached.bookId !== bookId;
     const bookChanged = cached.bookId !== bookId;
-
-    if (modelChanged || bookChanged) {
-      // Preserve conversation messages for re-injection
-      const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
-      agentCache.delete(sessionId);
+    const languageChanged = cached.language !== language;
+    const apiKeyChanged = cached.apiKey !== config.apiKey;
+    const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
+    const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
+
+    if (
+      modelChanged ||
+      projectRootChanged ||
+      bookChanged ||
+      languageChanged ||
+      apiKeyChanged ||
+      readPermissionChanged ||
+      transcriptChanged
+    ) {
+      agentCache.delete(cacheKey);
       cached = undefined;
       cached = undefined;
-      // Pass preserved messages as initialMessages if none were provided
-      if (!initialMessages || initialMessages.length === 0) {
-        initialMessages = preservedMessages;
-      }
     }
     }
   }
   }
 
 
   if (!cached) {
   if (!cached) {
-    const model = resolveModel(config.model);
+    const restoredMessages = adaptRestoredAgentMessagesForModel(
+      await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
+      model,
+    );
+    const initialAgentMessages = restoredMessages.length > 0
+      ? restoredMessages
+      : initialMessages && initialMessages.length > 0
+        ? plainToAgentMessages(initialMessages)
+        : [];
     const agent = new Agent({
     const agent = new Agent({
       initialState: {
       initialState: {
         model,
         model,
         systemPrompt: buildAgentSystemPrompt(bookId, language),
         systemPrompt: buildAgentSystemPrompt(bookId, language),
         tools: [
         tools: [
           createSubAgentTool(pipeline, bookId, projectRoot),
           createSubAgentTool(pipeline, bookId, projectRoot),
-          createReadTool(projectRoot),
+          createReadTool(projectRoot, { allowSystemPaths: allowSystemFileRead }),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),
           createWriteTruthFileTool(pipeline, projectRoot, bookId),
           createRenameEntityTool(pipeline, projectRoot, bookId),
           createRenameEntityTool(pipeline, projectRoot, bookId),
           createPatchChapterTextTool(pipeline, projectRoot, bookId),
           createPatchChapterTextTool(pipeline, projectRoot, bookId),
@@ -264,8 +555,10 @@ export async function runAgentSession(
           createGrepTool(projectRoot),
           createGrepTool(projectRoot),
           createLsTool(projectRoot),
           createLsTool(projectRoot),
         ],
         ],
+        messages: initialAgentMessages,
       },
       },
       transformContext: createBookContextTransform(bookId, projectRoot),
       transformContext: createBookContextTransform(bookId, projectRoot),
+      convertToLlm: (messages) => convertAgentMessagesForModel(messages, model),
       streamFn: streamSimple,
       streamFn: streamSimple,
       getApiKey: (provider: string) => {
       getApiKey: (provider: string) => {
         if (config.apiKey) return config.apiKey;
         if (config.apiKey) return config.apiKey;
@@ -273,54 +566,142 @@ export async function runAgentSession(
       },
       },
     });
     });
 
 
-    // Restore prior conversation if provided.
-    if (initialMessages && initialMessages.length > 0) {
-      agent.state.messages = plainToAgentMessages(initialMessages);
-    }
-
-    cached = { agent, bookId, lastActive: Date.now() };
-    agentCache.set(sessionId, cached);
+    cached = {
+      agent,
+      sessionId,
+      projectRoot,
+      bookId,
+      language,
+      modelIdentity: requestedModelIdentity,
+      apiKey: config.apiKey,
+      allowSystemFileRead,
+      lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
+      lastActive: Date.now(),
+    };
+    agentCache.set(cacheKey, cached);
     ensureCleanupTimer();
     ensureCleanupTimer();
   }
   }
 
 
   cached.lastActive = Date.now();
   cached.lastActive = Date.now();
   const { agent } = cached;
   const { agent } = cached;
 
 
-  // ----- Subscribe to events (for SSE streaming to frontend) -----
-  let unsubscribe: (() => void) | undefined;
-  if (onEvent) {
-    unsubscribe = agent.subscribe((event: AgentEvent) => {
-      onEvent(event);
-    });
-  }
+  // ----- Prepare transcript persistence -----
+  const requestId = randomUUID();
+  await ensureSessionCreatedEvent(projectRoot, sessionId, bookId);
+  await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
+    type: "request_started",
+    version: 1,
+    sessionId,
+    requestId,
+    seq,
+    timestamp: Date.now(),
+    input: userMessage,
+  }));
+
+  let parentUuid: string | null = null;
+  let piTurnIndex = 0;
+  let lastAssistantUuid: string | null = null;
+
+  const persistAgentEvent = async (event: AgentEvent): Promise<void> => {
+    if (event.type === "turn_start") {
+      piTurnIndex += 1;
+      return;
+    }
+    if (event.type !== "message_end") return;
+
+    const role = transcriptRoleForMessage(event.message);
+    if (!role) return;
+
+    const uuid = randomUUID();
+    const isToolResult = role === "toolResult";
+    const toolCallId = toolCallIdForMessage(event.message);
+    await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
+      type: "message",
+      version: 1,
+      sessionId,
+      requestId,
+      uuid,
+      parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
+      seq,
+      role,
+      timestamp: messageTimestamp(event.message),
+      piTurnIndex,
+      ...(toolCallId ? { toolCallId } : {}),
+      ...(isToolResult && lastAssistantUuid
+        ? { sourceToolAssistantUuid: lastAssistantUuid }
+        : {}),
+      message: event.message,
+    }));
+
+    if (role === "assistant") lastAssistantUuid = uuid;
+    parentUuid = uuid;
+  };
+
+  // ----- Subscribe to events (transcript persistence + SSE forwarding) -----
+  const unsubscribe = agent.subscribe(async (event: AgentEvent) => {
+    await persistAgentEvent(event);
+    onEvent?.(event);
+  });
 
 
   // ----- Execute the turn -----
   // ----- Execute the turn -----
+  let finalAssistant: AssistantMessage | undefined;
+  let errorMessage: string | undefined;
+
   try {
   try {
     await agent.prompt(userMessage);
     await agent.prompt(userMessage);
+
+    finalAssistant = lastAssistantMessage(agent.state.messages);
+    errorMessage = assistantErrorMessage(finalAssistant);
+    if (errorMessage) {
+      const failedError = errorMessage;
+      await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
+        type: "request_failed",
+        version: 1,
+        sessionId,
+        requestId,
+        seq,
+        timestamp: Date.now(),
+        error: failedError,
+      }));
+      agentCache.delete(cacheKey);
+    } else {
+      const committed = await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
+        type: "request_committed",
+        version: 1,
+        sessionId,
+        requestId,
+        seq,
+        timestamp: Date.now(),
+      }));
+      cached.lastCommittedSeq = committed.seq;
+    }
+  } catch (error) {
+    await appendAgentTranscriptEvent(projectRoot, sessionId, (seq) => ({
+      type: "request_failed",
+      version: 1,
+      sessionId,
+      requestId,
+      seq,
+      timestamp: Date.now(),
+      error: error instanceof Error ? error.message : String(error),
+    }));
+    agentCache.delete(cacheKey);
+    throw error;
   } finally {
   } finally {
-    unsubscribe?.();
+    unsubscribe();
   }
   }
 
 
   // ----- Extract result -----
   // ----- Extract result -----
   const allMessages = agent.state.messages;
   const allMessages = agent.state.messages;
-  const responseText = extractResponseText(allMessages);
-  const plainMessages = agentMessagesToPlain(allMessages);
-
-  return { responseText, messages: plainMessages };
-}
-
-/**
- * Walk backward through messages to find the last assistant message and
- * extract its text content.
- */
-function extractResponseText(messages: AgentMessage[]): string {
-  for (let i = messages.length - 1; i >= 0; i--) {
-    const msg = messages[i];
-    if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
-      return extractTextFromAssistant(msg as AssistantMessage);
-    }
-  }
-  return "";
+  finalAssistant ??= lastAssistantMessage(allMessages);
+  const responseText = finalAssistant ? extractTextFromAssistant(finalAssistant) : "";
+  errorMessage ??= assistantErrorMessage(finalAssistant);
+
+  return {
+    responseText,
+    messages: allMessages.slice(),
+    ...(errorMessage ? { errorMessage } : {}),
+  };
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -329,5 +710,11 @@ function extractResponseText(messages: AgentMessage[]): string {
 
 
 /** Manually evict a cached Agent session. */
 /** Manually evict a cached Agent session. */
 export function evictAgentCache(sessionId: string): boolean {
 export function evictAgentCache(sessionId: string): boolean {
-  return agentCache.delete(sessionId);
+  let deleted = agentCache.delete(sessionId);
+  for (const [key, entry] of agentCache) {
+    if (entry.sessionId !== sessionId) continue;
+    agentCache.delete(key);
+    deleted = true;
+  }
+  return deleted;
 }
 }

+ 31 - 9
packages/core/src/agent/agent-tools.ts

@@ -3,7 +3,7 @@ import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mario
 import type { PipelineRunner } from "../pipeline/runner.js";
 import type { PipelineRunner } from "../pipeline/runner.js";
 import { type ReviseMode } from "../agents/reviser.js";
 import { type ReviseMode } from "../agents/reviser.js";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
 import { readFile, writeFile, readdir, stat } from "node:fs/promises";
-import { join, normalize, resolve } from "node:path";
+import { isAbsolute, join, normalize, resolve } from "node:path";
 import { StateManager } from "../state/manager.js";
 import { StateManager } from "../state/manager.js";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
 import { writeExportArtifact } from "../interaction/export-artifact.js";
 import { writeExportArtifact } from "../interaction/export-artifact.js";
@@ -12,8 +12,10 @@ import { writeExportArtifact } from "../interaction/export-artifact.js";
 // Helpers
 // Helpers
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-function textResult(text: string): AgentToolResult<undefined> {
-  return { content: [{ type: "text", text }], details: undefined };
+function textResult(text: string): AgentToolResult<undefined>;
+function textResult<T>(text: string, details: T): AgentToolResult<T>;
+function textResult<T = undefined>(text: string, details?: T): AgentToolResult<T> {
+  return { content: [{ type: "text", text }], details: details as T };
 }
 }
 
 
 /**
 /**
@@ -125,7 +127,7 @@ export function createSubAgentTool(
       params: Static<typeof SubAgentParams>,
       params: Static<typeof SubAgentParams>,
       _signal?: AbortSignal,
       _signal?: AbortSignal,
       onUpdate?: AgentToolUpdateCallback,
       onUpdate?: AgentToolUpdateCallback,
-    ): Promise<AgentToolResult<undefined>> {
+    ): Promise<AgentToolResult<unknown>> {
       const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
       const { agent, instruction, bookId, title, chapterNumber, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params;
 
 
       const progress = (msg: string) => {
       const progress = (msg: string) => {
@@ -172,7 +174,10 @@ export function createSubAgentTool(
               { externalContext: instruction },
               { externalContext: instruction },
             );
             );
             progress(`Architect finished — book "${id}" foundation created.`);
             progress(`Architect finished — book "${id}" foundation created.`);
-            return textResult(`Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`);
+            return textResult(
+              `Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`,
+              { kind: "book_created", bookId: id, title: resolvedTitle },
+            );
           }
           }
 
 
           case "writer": {
           case "writer": {
@@ -335,15 +340,32 @@ export function createPatchChapterTextTool(
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 const ReadParams = Type.Object({
 const ReadParams = Type.Object({
-  path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
+  path: Type.String({ description: "File path relative to books/, or an absolute path when system path reading is enabled." }),
 });
 });
 
 
-export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
+export interface ReadToolOptions {
+  readonly allowSystemPaths?: boolean;
+}
+
+function resolveReadPath(booksRoot: string, requestedPath: string, options: ReadToolOptions): string {
+  if (options.allowSystemPaths && isAbsolute(requestedPath)) {
+    return resolve(requestedPath);
+  }
+  return safeBooksPath(booksRoot, requestedPath);
+}
+
+export function createReadTool(
+  projectRoot: string,
+  options: ReadToolOptions = {},
+): AgentTool<typeof ReadParams> {
   const booksRoot = join(projectRoot, "books");
   const booksRoot = join(projectRoot, "books");
+  const description = options.allowSystemPaths
+    ? "Read a file. Relative paths resolve under books/; absolute paths read from the system filesystem."
+    : "Read a file from the book directory. Path is relative to books/.";
 
 
   return {
   return {
     name: "read",
     name: "read",
-    description: "Read a file from the book directory. Path is relative to books/.",
+    description,
     label: "Read File",
     label: "Read File",
     parameters: ReadParams,
     parameters: ReadParams,
     async execute(
     async execute(
@@ -351,7 +373,7 @@ export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams
       params: Static<typeof ReadParams>,
       params: Static<typeof ReadParams>,
     ): Promise<AgentToolResult<undefined>> {
     ): Promise<AgentToolResult<undefined>> {
       try {
       try {
-        const filePath = safeBooksPath(booksRoot, params.path);
+        const filePath = resolveReadPath(booksRoot, params.path, options);
         let content = await readFile(filePath, "utf-8");
         let content = await readFile(filePath, "utf-8");
         if (content.length > 10_000) {
         if (content.length > 10_000) {
           content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
           content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";

+ 3 - 3
packages/core/src/agents/architect.ts

@@ -149,7 +149,7 @@ export class ArchitectAgent extends BaseAgent {
     const response = await this.chat([
     const response = await this.chat([
       { role: "system", content: langPrefix + systemPrompt + revisePrompt },
       { role: "system", content: langPrefix + systemPrompt + revisePrompt },
       { role: "user", content: userMessage },
       { role: "user", content: userMessage },
-    ], { maxTokens: 20480, temperature: 0.8 });
+    ], { temperature: 0.8 });
 
 
     return this.parseSections(response.content, resolvedLanguage);
     return this.parseSections(response.content, resolvedLanguage);
   }
   }
@@ -974,7 +974,7 @@ ${continuationDirective}
     const response = await this.chat([
     const response = await this.chat([
       { role: "system", content: systemPrompt },
       { role: "system", content: systemPrompt },
       { role: "user", content: userMessage },
       { role: "user", content: userMessage },
-    ], { maxTokens: 20480, temperature: 0.5 });
+    ], { temperature: 0.5 });
 
 
     return this.parseSections(response.content, resolvedLanguage);
     return this.parseSections(response.content, resolvedLanguage);
   }
   }
@@ -1031,7 +1031,7 @@ ${genreBody}
         role: "user",
         role: "user",
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
         content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
       },
       },
-    ], { maxTokens: 20480, temperature: 0.7 });
+    ], { temperature: 0.7 });
 
 
     return this.parseSections(response.content, book.language ?? "zh");
     return this.parseSections(response.content, book.language ?? "zh");
   }
   }

+ 1 - 4
packages/core/src/agents/length-normalizer.ts

@@ -49,10 +49,7 @@ export class LengthNormalizerAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      {
-        temperature: 0.2,
-        maxTokens: Math.max(4096, Math.ceil(originalCount * 1.2)),
-      },
+      { temperature: 0.2 },
     );
     );
 
 
     const normalizedContent = this.sanitizeNormalizedContent(response.content, input.chapterContent);
     const normalizedContent = this.sanitizeNormalizedContent(response.content, input.chapterContent);

+ 1 - 1
packages/core/src/agents/planner.ts

@@ -243,7 +243,7 @@ export class PlannerAgent extends BaseAgent {
           { role: "system", content: systemPrompt },
           { role: "system", content: systemPrompt },
           { role: "user", content: currentUserMessage },
           { role: "user", content: currentUserMessage },
         ],
         ],
-        { temperature: 0.7, maxTokens: 2000 },
+        { temperature: 0.7 },
       );
       );
 
 
       try {
       try {

+ 1 - 1
packages/core/src/agents/polisher.ts

@@ -57,7 +57,7 @@ export class PolisherAgent extends BaseAgent {
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      { temperature: input.temperature ?? 0.4, maxTokens: 16384 },
+      { temperature: input.temperature ?? 0.4 },
     );
     );
 
 
     const raw = response.content.trim();
     const raw = response.content.trim();

+ 1 - 1
packages/core/src/agents/reviser.ts

@@ -281,7 +281,7 @@ ${chapterContent}`;
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
         { role: "user", content: userPrompt },
       ],
       ],
-      { temperature: 0.3, maxTokens: 16384 },
+      { temperature: 0.3 },
     );
     );
 
 
     const output = this.parseOutput(
     const output = this.parseOutput(

+ 2 - 8
packages/core/src/agents/writer.ts

@@ -260,15 +260,12 @@ export class WriterAgent extends BaseAgent {
       en: `Phase 1: creative writing for chapter ${chapterNumber}`,
       en: `Phase 1: creative writing for chapter ${chapterNumber}`,
     });
     });
 
 
-    // Scale maxTokens to chapter word count (Chinese ≈ 1.5 tokens/char)
-    const creativeMaxTokens = Math.max(8192, Math.ceil(targetWords * 2));
-
     const creativeResponse = await this.chat(
     const creativeResponse = await this.chat(
       [
       [
         { role: "system", content: creativeSystemPrompt },
         { role: "system", content: creativeSystemPrompt },
         { role: "user", content: creativeUserPrompt },
         { role: "user", content: creativeUserPrompt },
       ],
       ],
-      { maxTokens: creativeMaxTokens, temperature: creativeTemperature },
+      { temperature: creativeTemperature },
     );
     );
     const creativeUsage = creativeResponse.usage;
     const creativeUsage = creativeResponse.usage;
 
 
@@ -610,15 +607,12 @@ export class WriterAgent extends BaseAgent {
       validationFeedback: params.validationFeedback,
       validationFeedback: params.validationFeedback,
     });
     });
 
 
-    // Settler outputs all truth files — scale with content size
-    const settlerMaxTokens = Math.max(8192, Math.ceil(params.content.length * 0.8));
-
     const response = await this.chat(
     const response = await this.chat(
       [
       [
         { role: "system", content: settlerSystem },
         { role: "system", content: settlerSystem },
         { role: "user", content: settlerUser },
         { role: "user", content: settlerUser },
       ],
       ],
-      { maxTokens: settlerMaxTokens, temperature: 0.3 },
+      { temperature: 0.3 },
     );
     );
 
 
     let mergedSettlement: ReturnType<typeof parseSettlementOutput> & {
     let mergedSettlement: ReturnType<typeof parseSettlementOutput> & {

+ 33 - 0
packages/core/src/index.ts

@@ -142,6 +142,39 @@ export {
   createAndPersistBookSession,
   createAndPersistBookSession,
   SessionAlreadyMigratedError,
   SessionAlreadyMigratedError,
 } from "./interaction/book-session-store.js";
 } from "./interaction/book-session-store.js";
+export {
+  appendManualSessionMessages,
+  appendTranscriptEvent,
+  sessionsDir,
+  readTranscriptEvents,
+  nextTranscriptSeq,
+  transcriptPath,
+  legacyBookSessionPath,
+} from "./interaction/session-transcript.js";
+export {
+  cleanRestoredAgentMessages,
+  committedMessageEvents,
+  deriveBookSessionFromTranscript,
+  restoreAgentMessagesFromTranscript,
+} from "./interaction/session-transcript-restore.js";
+export {
+  MessageEventSchema,
+  RequestCommittedEventSchema,
+  RequestFailedEventSchema,
+  RequestStartedEventSchema,
+  SessionCreatedEventSchema,
+  SessionMetadataUpdatedEventSchema,
+  TranscriptEventSchema,
+} from "./interaction/session-transcript-schema.js";
+export type {
+  TranscriptEvent,
+  MessageEvent,
+  RequestCommittedEvent,
+  RequestFailedEvent,
+  RequestStartedEvent,
+  SessionCreatedEvent,
+  SessionMetadataUpdatedEvent,
+} from "./interaction/session-transcript-schema.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
 export {
   routeNaturalLanguageIntent,
   routeNaturalLanguageIntent,

+ 107 - 81
packages/core/src/interaction/book-session-store.ts

@@ -1,17 +1,18 @@
-import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
-import { join } from "node:path";
-import { BookSessionSchema, createBookSession } from "./session.js";
+import { readdir, unlink } from "node:fs/promises";
+import { createBookSession } from "./session.js";
 import type { BookSession } from "./session.js";
 import type { BookSession } from "./session.js";
-
-const SESSIONS_DIR = ".inkos/sessions";
-
-function sessionsDir(projectRoot: string): string {
-  return join(projectRoot, SESSIONS_DIR);
-}
-
-function sessionPath(projectRoot: string, sessionId: string): string {
-  return join(sessionsDir(projectRoot), `${sessionId}.json`);
-}
+import {
+  appendTranscriptEvents,
+  legacyBookSessionPath,
+  readTranscriptEvents,
+  sessionsDir,
+  transcriptPath,
+} from "./session-transcript.js";
+import {
+  migrateLegacyBookSessionToTranscript,
+  readLegacyBookSession,
+} from "./session-transcript-legacy.js";
+import { deriveBookSessionFromTranscript } from "./session-transcript-restore.js";
 
 
 /**
 /**
  * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
  * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
@@ -42,24 +43,76 @@ export async function loadBookSession(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
 ): Promise<BookSession | null> {
 ): Promise<BookSession | null> {
-  try {
-    const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
-    return BookSessionSchema.parse(JSON.parse(raw));
-  } catch {
-    return null;
-  }
+  const transcriptSession = await deriveBookSessionFromTranscript(projectRoot, sessionId);
+  if (transcriptSession) return transcriptSession;
+
+  const legacySession = await readLegacyBookSession(projectRoot, sessionId);
+  if (!legacySession) return null;
+
+  await migrateLegacyBookSessionToTranscript(projectRoot, legacySession);
+  return await deriveBookSessionFromTranscript(projectRoot, sessionId) ?? legacySession;
+}
+
+async function appendSessionCreatedEvent(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  await appendTranscriptEvents(projectRoot, session.sessionId, ({ events, nextSeq }) => {
+    if (events.some((event) => event.type === "session_created")) return [];
+    return [{
+      type: "session_created",
+      version: 1,
+      sessionId: session.sessionId,
+      seq: nextSeq,
+      timestamp: session.createdAt,
+      bookId: session.bookId,
+      title: session.title,
+      createdAt: session.createdAt,
+      updatedAt: session.updatedAt,
+    }];
+  });
+}
+
+async function appendSessionMetadataUpdatedEvent(
+  projectRoot: string,
+  sessionId: string,
+  metadata: {
+    readonly bookId?: string | null;
+    readonly title?: string | null;
+    readonly updatedAt: number;
+  },
+): Promise<void> {
+  await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => [{
+    type: "session_metadata_updated",
+    version: 1,
+    sessionId,
+    seq: nextSeq,
+    timestamp: metadata.updatedAt,
+    updatedAt: metadata.updatedAt,
+    ...("bookId" in metadata ? { bookId: metadata.bookId } : {}),
+    ...("title" in metadata ? { title: metadata.title } : {}),
+  }]);
 }
 }
 
 
 export async function persistBookSession(
 export async function persistBookSession(
   projectRoot: string,
   projectRoot: string,
   session: BookSession,
   session: BookSession,
 ): Promise<void> {
 ): Promise<void> {
-  const dir = sessionsDir(projectRoot);
-  await mkdir(dir, { recursive: true });
-  await writeFile(
-    sessionPath(projectRoot, session.sessionId),
-    JSON.stringify(session, null, 2),
-  );
+  const events = await readTranscriptEvents(projectRoot, session.sessionId);
+  if (events.length === 0) {
+    if (session.messages.length === 0) {
+      await appendSessionCreatedEvent(projectRoot, session);
+      return;
+    }
+    await migrateLegacyBookSessionToTranscript(projectRoot, session);
+    return;
+  }
+
+  await appendSessionMetadataUpdatedEvent(projectRoot, session.sessionId, {
+    bookId: session.bookId,
+    title: session.title,
+    updatedAt: session.updatedAt,
+  });
 }
 }
 
 
 export interface BookSessionSummary {
 export interface BookSessionSummary {
@@ -83,52 +136,28 @@ export async function listBookSessions(
     return [];
     return [];
   }
   }
 
 
-  const jsonFiles = files.filter((file) => file.endsWith(".json"));
+  const sessionIds = new Set<string>();
+  for (const file of files) {
+    if (file.endsWith(".jsonl")) {
+      sessionIds.add(file.slice(0, -".jsonl".length));
+    } else if (file.endsWith(".json")) {
+      sessionIds.add(file.slice(0, -".json".length));
+    }
+  }
+
   const summaries = await Promise.all(
   const summaries = await Promise.all(
-    jsonFiles.map(async (file): Promise<BookSessionSummary | null> => {
+    [...sessionIds].map(async (sessionId): Promise<BookSessionSummary | null> => {
       try {
       try {
-        const raw = await readFile(join(dir, file), "utf-8");
-        const data = JSON.parse(raw) as {
-          sessionId?: unknown;
-          bookId?: unknown;
-          title?: unknown;
-          messages?: unknown;
-          createdAt?: unknown;
-          updatedAt?: unknown;
-        };
-        if (typeof data.sessionId !== "string") return null;
-        const parsedBookId = data.bookId === null || typeof data.bookId === "string"
-          ? (data.bookId as string | null)
-          : null;
-        if (parsedBookId !== bookId) return null;
-
-        let persistedTitle = typeof data.title === "string" ? data.title : null;
-
-        // Lazy migration:老 session 的 title 字段是 null 但已经有用户消息的,
-        // 一次性把第一条用户消息补写成 title 并 persist 回磁盘。用户在新流程中
-        // 发消息时会立即写 title,此 migration 只对历史数据生效一次。
-        if (persistedTitle === null) {
-          const recoveredTitle = extractFirstUserMessageTitle(data.messages);
-          if (recoveredTitle) {
-            try {
-              const fullSession = await loadBookSession(projectRoot, data.sessionId);
-              if (fullSession && fullSession.title === null) {
-                await persistBookSession(projectRoot, { ...fullSession, title: recoveredTitle });
-                persistedTitle = recoveredTitle;
-              }
-            } catch {
-              // 读不出完整 session 就忽略;下次再试
-            }
-          }
-        }
+        const session = await loadBookSession(projectRoot, sessionId);
+        if (!session || session.bookId !== bookId) return null;
 
 
         return {
         return {
-          sessionId: data.sessionId,
-          bookId: parsedBookId,
-          title: persistedTitle,
-          messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
-          createdAt: typeof data.createdAt === "number" ? data.createdAt : 0,
-          updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
+          sessionId: session.sessionId,
+          bookId: session.bookId,
+          title: session.title,
+          messageCount: session.messages.length,
+          createdAt: session.createdAt,
+          updatedAt: session.updatedAt,
         };
         };
       } catch {
       } catch {
         return null;
         return null;
@@ -148,20 +177,19 @@ export async function renameBookSession(
 ): Promise<BookSession | null> {
 ): Promise<BookSession | null> {
   const session = await loadBookSession(projectRoot, sessionId);
   const session = await loadBookSession(projectRoot, sessionId);
   if (!session) return null;
   if (!session) return null;
-  const updated = { ...session, title, updatedAt: Date.now() };
-  await persistBookSession(projectRoot, updated);
-  return updated;
+  const updatedAt = Date.now();
+  await appendSessionMetadataUpdatedEvent(projectRoot, sessionId, { title, updatedAt });
+  return loadBookSession(projectRoot, sessionId);
 }
 }
 
 
 export async function deleteBookSession(
 export async function deleteBookSession(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
 ): Promise<void> {
 ): Promise<void> {
-  try {
-    await unlink(sessionPath(projectRoot, sessionId));
-  } catch {
-    // Session file is already absent; treat delete as idempotent.
-  }
+  await Promise.all([
+    unlink(transcriptPath(projectRoot, sessionId)).catch(() => undefined),
+    unlink(legacyBookSessionPath(projectRoot, sessionId)).catch(() => undefined),
+  ]);
 }
 }
 
 
 export async function migrateBookSession(
 export async function migrateBookSession(
@@ -175,13 +203,11 @@ export async function migrateBookSession(
     throw new SessionAlreadyMigratedError(sessionId, session.bookId);
     throw new SessionAlreadyMigratedError(sessionId, session.bookId);
   }
   }
 
 
-  const updated = {
-    ...session,
+  await appendSessionMetadataUpdatedEvent(projectRoot, sessionId, {
     bookId: newBookId,
     bookId: newBookId,
     updatedAt: Date.now(),
     updatedAt: Date.now(),
-  };
-  await persistBookSession(projectRoot, updated);
-  return updated;
+  });
+  return loadBookSession(projectRoot, sessionId);
 }
 }
 
 
 export async function createAndPersistBookSession(
 export async function createAndPersistBookSession(
@@ -195,6 +221,6 @@ export async function createAndPersistBookSession(
     if (existing) return existing;
     if (existing) return existing;
   }
   }
   const session = createBookSession(bookId, sessionId);
   const session = createBookSession(bookId, sessionId);
-  await persistBookSession(projectRoot, session);
+  await appendSessionCreatedEvent(projectRoot, session);
   return session;
   return session;
 }
 }

+ 113 - 0
packages/core/src/interaction/session-transcript-legacy.ts

@@ -0,0 +1,113 @@
+import { randomUUID } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { BookSessionSchema, type BookSession } from "./session.js";
+import {
+  appendTranscriptEvents,
+  legacyBookSessionPath,
+} from "./session-transcript.js";
+import type { MessageEvent } from "./session-transcript-schema.js";
+
+const EMPTY_USAGE = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+export async function readLegacyBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  try {
+    const raw = await readFile(legacyBookSessionPath(projectRoot, sessionId), "utf-8");
+    return BookSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
+export async function migrateLegacyBookSessionToTranscript(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  await appendTranscriptEvents(projectRoot, session.sessionId, ({ events, nextSeq }) => {
+    if (events.length > 0) return [];
+
+    const sessionCreatedSeq = nextSeq;
+    const requestStartedSeq = nextSeq + 1;
+    let messageSeq = nextSeq + 2;
+    const requestId = `legacy-${randomUUID()}`;
+    const transcriptEvents: MessageEvent[] = [];
+    let parentUuid: string | null = null;
+
+    for (const legacyMessage of session.messages) {
+      const uuid = randomUUID();
+      const message = legacyMessage.role === "assistant"
+        ? {
+            role: "assistant",
+            content: [{ type: "text", text: legacyMessage.content }],
+            api: "anthropic-messages",
+            provider: "legacy",
+            model: "unknown",
+            usage: EMPTY_USAGE,
+            stopReason: "stop",
+            timestamp: legacyMessage.timestamp,
+          }
+        : {
+            role: legacyMessage.role,
+            content: legacyMessage.content,
+            timestamp: legacyMessage.timestamp,
+          };
+      transcriptEvents.push({
+        type: "message",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        uuid,
+        parentUuid,
+        seq: messageSeq++,
+        role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
+        timestamp: legacyMessage.timestamp,
+        ...(legacyMessage.role === "assistant" && legacyMessage.thinking
+          ? { legacyDisplay: { thinking: legacyMessage.thinking } }
+          : {}),
+        message,
+      });
+      parentUuid = uuid;
+    }
+
+    return [
+      {
+        type: "session_created",
+        version: 1,
+        sessionId: session.sessionId,
+        seq: sessionCreatedSeq,
+        timestamp: session.createdAt,
+        bookId: session.bookId,
+        title: session.title,
+        createdAt: session.createdAt,
+        updatedAt: session.updatedAt,
+      },
+      {
+        type: "request_started",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        seq: requestStartedSeq,
+        timestamp: session.createdAt,
+        input: "",
+      },
+      ...transcriptEvents,
+      {
+        type: "request_committed",
+        version: 1,
+        sessionId: session.sessionId,
+        requestId,
+        seq: messageSeq,
+        timestamp: session.updatedAt,
+      },
+    ];
+  });
+}

+ 603 - 0
packages/core/src/interaction/session-transcript-restore.ts

@@ -0,0 +1,603 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import { readTranscriptEvents } from "./session-transcript.js";
+import {
+  BookSessionSchema,
+  type BookSession,
+  type InteractionMessage,
+  type ToolExecution,
+} from "./session.js";
+import type { MessageEvent, TranscriptEvent } from "./session-transcript-schema.js";
+
+function isObject(value: unknown): value is Record<string, unknown> {
+  return !!value && typeof value === "object";
+}
+
+function contentBlocks(message: Record<string, unknown>): unknown[] {
+  return Array.isArray(message.content) ? message.content : [];
+}
+
+function hasTextContent(message: Record<string, unknown>): boolean {
+  return contentBlocks(message).some(
+    (block) =>
+      isObject(block) &&
+      block.type === "text" &&
+      typeof block.text === "string" &&
+      block.text.length > 0,
+  );
+}
+
+function hasToolCallContent(message: Record<string, unknown>): boolean {
+  return contentBlocks(message).some(
+    (block) => isObject(block) && block.type === "toolCall" && typeof block.id === "string",
+  );
+}
+
+function toolCallIds(message: Record<string, unknown>): string[] {
+  return contentBlocks(message)
+    .filter(
+      (block): block is Record<string, unknown> =>
+        isObject(block) && block.type === "toolCall" && typeof block.id === "string",
+    )
+    .map((block) => block.id as string);
+}
+
+function isThinkingBlock(block: unknown): boolean {
+  return isObject(block) && (block.type === "thinking" || block.type === "redacted_thinking");
+}
+
+function removeTrailingThinking(message: AgentMessage): AgentMessage {
+  if (!isObject(message) || message.role !== "assistant" || !Array.isArray(message.content)) {
+    return message;
+  }
+
+  const content = [...message.content];
+  while (content.length > 0 && isThinkingBlock(content[content.length - 1])) {
+    content.pop();
+  }
+
+  if (content.length === message.content.length) return message;
+  return { ...message, content } as AgentMessage;
+}
+
+const emptyUsage = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+export const TOOL_RESULT_BRIDGE_TEXT = "I have processed the tool results.";
+
+function toolResultBridgeMessage(timestamp: number): AgentMessage {
+  return {
+    role: "assistant",
+    content: [{ type: "text", text: TOOL_RESULT_BRIDGE_TEXT }],
+    api: "openai-completions",
+    provider: "inkos",
+    model: "synthetic-tool-result-bridge",
+    usage: emptyUsage,
+    stopReason: "stop",
+    timestamp,
+  } as AgentMessage;
+}
+
+function addToolResultBridges(messages: AgentMessage[]): AgentMessage[] {
+  const bridged: AgentMessage[] = [];
+
+  for (let i = 0; i < messages.length; i++) {
+    const message = messages[i];
+    bridged.push(message);
+
+    if (!isObject(message) || message.role !== "toolResult") continue;
+
+    const next = messages[i + 1];
+    if (isObject(next) && (next.role === "toolResult" || next.role === "assistant")) continue;
+
+    const timestamp = typeof message.timestamp === "number" ? message.timestamp + 1 : Date.now();
+    bridged.push(toolResultBridgeMessage(timestamp));
+  }
+
+  return bridged;
+}
+
+export function cleanRestoredAgentMessages(messages: AgentMessage[]): AgentMessage[] {
+  const availableToolCalls = new Set<string>();
+  for (const message of messages) {
+    if (isObject(message) && message.role === "assistant") {
+      for (const id of toolCallIds(message)) availableToolCalls.add(id);
+    }
+  }
+
+  const cleaned = messages.filter((message) => {
+    if (!isObject(message)) return false;
+    if (message.role === "toolResult") {
+      return typeof message.toolCallId === "string" && availableToolCalls.has(message.toolCallId);
+    }
+    if (message.role === "assistant") {
+      return hasTextContent(message) || hasToolCallContent(message);
+    }
+    return message.role === "user" || message.role === "system";
+  });
+
+  if (cleaned.length === 0) return cleaned;
+  const last = cleaned[cleaned.length - 1];
+  if (isObject(last) && last.role === "assistant") {
+    cleaned[cleaned.length - 1] = removeTrailingThinking(last);
+  }
+
+  return cleaned;
+}
+
+interface TargetModelIdentity {
+  readonly api?: unknown;
+  readonly provider?: unknown;
+  readonly id?: unknown;
+  readonly compat?: unknown;
+}
+
+function requiresAssistantAfterToolResult(target: TargetModelIdentity): boolean {
+  return !!(
+    target.compat &&
+    typeof target.compat === "object" &&
+    (target.compat as { requiresAssistantAfterToolResult?: unknown }).requiresAssistantAfterToolResult === true
+  );
+}
+
+function isSameAssistantModel(message: Record<string, unknown>, target: TargetModelIdentity): boolean {
+  return (
+    typeof message.api === "string" &&
+    typeof message.provider === "string" &&
+    typeof message.model === "string" &&
+    message.api === target.api &&
+    message.provider === target.provider &&
+    message.model === target.id
+  );
+}
+
+export function adaptRestoredAgentMessagesForModel(
+  messages: AgentMessage[],
+  target: TargetModelIdentity,
+): AgentMessage[] {
+  const adapted: AgentMessage[] = [];
+  const nativeToolCallIds = new Set<string>();
+
+  const pushToolResultsAsUser = (toolResults: AgentMessage[]) => {
+    const lines = toolResults.flatMap((message) => {
+      const raw: Record<string, unknown> = isObject(message) ? message : {};
+      const toolName = typeof raw.toolName === "string" ? raw.toolName : "tool";
+      const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId : "unknown";
+      const text = contentBlocks(raw)
+        .map((block) => {
+          if (isObject(block) && block.type === "text" && typeof block.text === "string") {
+            return block.text;
+          }
+          return "";
+        })
+        .filter(Boolean)
+        .join("\n")
+        .trim() || "(empty tool result)";
+      return [`- ${toolName} (${toolCallId}):`, text];
+    });
+    const timestamp = toolResults.reduce((max, message) => {
+      if (isObject(message) && typeof message.timestamp === "number") {
+        return Math.max(max, message.timestamp);
+      }
+      return max;
+    }, 0) || Date.now();
+    adapted.push({
+      role: "user",
+      content: [
+        "[Tool results]",
+        ...lines,
+        "These tool results were restored from a previous model run. Use them as context only.",
+      ].join("\n"),
+      timestamp,
+    } as AgentMessage);
+  };
+
+  for (let index = 0; index < messages.length; index++) {
+    const message = messages[index];
+    if (!isObject(message)) continue;
+
+    if (message.role === "assistant") {
+      const content = contentBlocks(message);
+      const isBridge = content.length === 1 &&
+        isObject(content[0]) &&
+        content[0].type === "text" &&
+        typeof content[0].text === "string" &&
+        content[0].text.trim() === TOOL_RESULT_BRIDGE_TEXT;
+      const previous = adapted[adapted.length - 1];
+      if (
+        isBridge &&
+        isObject(previous) &&
+        previous.role === "user" &&
+        typeof previous.content === "string" &&
+        previous.content.startsWith("[Tool results]")
+      ) {
+        continue;
+      }
+
+      if (Array.isArray(message.content) && isSameAssistantModel(message, target)) {
+        adapted.push(message);
+        for (const id of toolCallIds(message)) nativeToolCallIds.add(id);
+        continue;
+      }
+
+      const contentWithoutThinking = content.filter((block) => !isThinkingBlock(block));
+      const foreignToolCallIds = new Set(
+        contentWithoutThinking
+          .filter(
+            (block): block is Record<string, unknown> =>
+              isObject(block) && block.type === "toolCall" && typeof block.id === "string",
+          )
+          .map((block) => block.id as string),
+      );
+
+      if (foreignToolCallIds.size === 0) {
+        const rewritten = contentWithoutThinking.length === content.length
+          ? message
+          : ({ ...message, content: contentWithoutThinking } as AgentMessage);
+        if (
+          contentWithoutThinking.some(
+            (block) =>
+              isObject(block) &&
+              block.type === "text" &&
+              typeof block.text === "string" &&
+              block.text.length > 0,
+          )
+        ) {
+          adapted.push(rewritten);
+        }
+        continue;
+      }
+
+      const textContent = contentWithoutThinking.filter(
+        (block): block is { type: "text"; text: string } =>
+          isObject(block) &&
+          block.type === "text" &&
+          typeof block.text === "string" &&
+          block.text.trim().length > 0,
+      );
+      if (textContent.length > 0) {
+        adapted.push({ ...message, content: textContent } as AgentMessage);
+      }
+
+      const toolResults: AgentMessage[] = [];
+      let nextIndex = index + 1;
+      while (nextIndex < messages.length) {
+        const next = messages[nextIndex];
+        if (
+          !isObject(next) ||
+          next.role !== "toolResult" ||
+          typeof next.toolCallId !== "string" ||
+          !foreignToolCallIds.has(next.toolCallId)
+        ) {
+          break;
+        }
+        toolResults.push(next);
+        nextIndex += 1;
+      }
+      if (toolResults.length > 0) {
+        pushToolResultsAsUser(toolResults);
+        index = nextIndex - 1;
+      }
+      continue;
+    }
+
+    if (message.role === "toolResult") {
+      const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
+      if (nativeToolCallIds.has(toolCallId)) {
+        adapted.push(message);
+      } else {
+        pushToolResultsAsUser([message]);
+      }
+      continue;
+    }
+
+    adapted.push(message);
+  }
+
+  const filtered = adapted.filter((message) => {
+    if (!isObject(message) || message.role !== "assistant") return true;
+    return hasTextContent(message) || hasToolCallContent(message);
+  });
+
+  return requiresAssistantAfterToolResult(target)
+    ? addToolResultBridges(filtered)
+    : filtered;
+}
+
+export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {
+  const committed = new Set(
+    events
+      .filter((event) => event.type === "request_committed")
+      .map((event) => event.requestId),
+  );
+
+  return events
+    .filter((event): event is MessageEvent => event.type === "message" && committed.has(event.requestId))
+    .sort((a, b) => a.seq - b.seq);
+}
+
+export async function restoreAgentMessagesFromTranscript(
+  projectRoot: string,
+  sessionId: string,
+): Promise<AgentMessage[]> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  return cleanRestoredAgentMessages(
+    committedMessageEvents(events).map((event) => event.message as AgentMessage),
+  );
+}
+
+function textFromContent(content: unknown): string {
+  if (typeof content === "string") return content;
+  if (!Array.isArray(content)) return "";
+  return content
+    .filter(
+      (block): block is { type: string; text: string } =>
+        isObject(block) && block.type === "text" && typeof block.text === "string",
+    )
+    .map((block) => block.text)
+    .join("");
+}
+
+function thinkingFromContent(content: unknown): string | undefined {
+  if (!Array.isArray(content)) return undefined;
+  const value = content
+    .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "thinking")
+    .map((block) => typeof block.thinking === "string" ? block.thinking : "")
+    .join("");
+  return value || undefined;
+}
+
+function joinThinking(parts: ReadonlyArray<string | undefined>): string | undefined {
+  const values = parts
+    .map((part) => part?.trim() ?? "")
+    .filter((part) => part.length > 0);
+  return values.length > 0 ? values.join("\n\n---\n\n") : undefined;
+}
+
+function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
+  for (const message of messages) {
+    if (message.role !== "user") continue;
+    const oneLine = message.content.trim().replace(/\s+/g, " ");
+    if (!oneLine) return null;
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return null;
+}
+
+function messageEventToInteractionMessage(
+  event: MessageEvent,
+  restoredToolExecutions?: ToolExecution[],
+  restoredThinking?: ReadonlyArray<string>,
+): InteractionMessage | null {
+  const raw = event.message as Record<string, unknown>;
+  if (!isObject(raw)) return null;
+  if (event.role === "toolResult") return null;
+
+  if (event.role === "user") {
+    const content = textFromContent(raw.content);
+    return content ? { role: "user", content, timestamp: event.timestamp } : null;
+  }
+
+  if (event.role === "assistant") {
+    const content = textFromContent(raw.content);
+    const thinking = joinThinking([
+      ...(restoredThinking ?? []),
+      thinkingFromContent(raw.content),
+      event.legacyDisplay?.thinking,
+    ]);
+    const toolExecutions = restoredToolExecutions?.length
+      ? restoredToolExecutions
+      : event.legacyDisplay?.toolExecutions as ToolExecution[] | undefined;
+    if (!content) return null;
+    return {
+      role: "assistant",
+      content,
+      ...(thinking ? { thinking } : {}),
+      ...(toolExecutions?.length ? { toolExecutions } : {}),
+      timestamp: event.timestamp,
+    };
+  }
+
+  if (event.role === "system") {
+    const content = textFromContent(raw.content);
+    return content ? { role: "system", content, timestamp: event.timestamp } : null;
+  }
+
+  return null;
+}
+
+function messageEventsToInteractionMessages(events: MessageEvent[]): InteractionMessage[] {
+  type RestoredToolCall = {
+    id: string;
+    tool: string;
+    args?: Record<string, unknown>;
+    timestamp: number;
+  };
+
+  const agentLabels: Record<string, string> = {
+    architect: "建书",
+    writer: "写作",
+    auditor: "审计",
+    reviser: "修订",
+    exporter: "导出",
+  };
+  const toolLabels: Record<string, string> = {
+    read: "读取文件",
+    edit: "编辑文件",
+    grep: "搜索",
+    ls: "列目录",
+  };
+
+  const messages: InteractionMessage[] = [];
+  const toolCalls = new Map<string, RestoredToolCall>();
+  let pendingToolExecutions: ToolExecution[] = [];
+  let pendingThinking: string[] = [];
+  let activeRequestId: string | null = null;
+
+  const clearPending = () => {
+    pendingToolExecutions = [];
+    pendingThinking = [];
+  };
+  const toolCallKey = (requestId: string, toolCallId: string): string =>
+    `${requestId}\0${toolCallId}`;
+
+  const objectArgs = (value: unknown): Record<string, unknown> | undefined => {
+    if (isObject(value)) return value;
+    if (typeof value !== "string" || !value.trim()) return undefined;
+    try {
+      const parsed: unknown = JSON.parse(value);
+      return isObject(parsed) ? parsed : undefined;
+    } catch {
+      return undefined;
+    }
+  };
+
+  const resolveToolLabel = (tool: string, agent?: string): string => {
+    if (tool === "sub_agent" && agent) return agentLabels[agent] ?? agent;
+    return toolLabels[tool] ?? tool;
+  };
+
+  const rememberToolCalls = (event: MessageEvent, raw: Record<string, unknown>) => {
+    for (const block of contentBlocks(raw)) {
+      if (!isObject(block) || block.type !== "toolCall") continue;
+      if (typeof block.id !== "string" || !block.id) continue;
+      const tool = typeof block.name === "string" && block.name ? block.name : "tool";
+      const args = objectArgs(block.arguments);
+      toolCalls.set(toolCallKey(event.requestId, block.id), {
+        id: block.id,
+        tool,
+        ...(args ? { args } : {}),
+        timestamp: event.timestamp,
+      });
+    }
+  };
+
+  const toolExecutionFromResult = (event: MessageEvent): ToolExecution | null => {
+    const raw = event.message as Record<string, unknown>;
+    if (!isObject(raw)) return null;
+    const toolCallId = typeof raw.toolCallId === "string"
+      ? raw.toolCallId
+      : typeof event.toolCallId === "string"
+        ? event.toolCallId
+        : "";
+    if (!toolCallId) return null;
+
+    const call = toolCalls.get(toolCallKey(event.requestId, toolCallId));
+    const tool = typeof raw.toolName === "string" && raw.toolName
+      ? raw.toolName
+      : call?.tool ?? "tool";
+    const args = call?.args;
+    const agent = tool === "sub_agent" && typeof args?.agent === "string"
+      ? args.agent
+      : undefined;
+    const text = textFromContent(raw.content).trim();
+    const isError = raw.isError === true;
+
+    return {
+      id: toolCallId,
+      tool,
+      ...(agent ? { agent } : {}),
+      label: resolveToolLabel(tool, agent),
+      status: isError ? "error" : "completed",
+      ...(args ? { args } : {}),
+      ...(isError
+        ? { error: text.slice(0, 500) || "Tool execution failed" }
+        : text
+          ? { result: text.slice(0, 200) }
+          : {}),
+      startedAt: call?.timestamp ?? event.timestamp,
+      completedAt: event.timestamp,
+    };
+  };
+
+  for (const event of events) {
+    const raw = event.message as Record<string, unknown>;
+    if (activeRequestId !== event.requestId) {
+      activeRequestId = event.requestId;
+      clearPending();
+    }
+
+    if (event.role === "assistant" && isObject(raw)) {
+      rememberToolCalls(event, raw);
+      const currentThinking = thinkingFromContent(raw.content);
+      const message = messageEventToInteractionMessage(
+        event,
+        pendingToolExecutions.length > 0 ? pendingToolExecutions : undefined,
+        pendingThinking.length > 0 ? pendingThinking : undefined,
+      );
+      if (message) {
+        messages.push(message);
+        clearPending();
+      } else if (hasToolCallContent(raw) && currentThinking) {
+        pendingThinking.push(currentThinking);
+      }
+      continue;
+    }
+
+    if (event.role === "toolResult") {
+      const execution = toolExecutionFromResult(event);
+      if (execution) pendingToolExecutions.push(execution);
+      continue;
+    }
+
+    clearPending();
+    const message = messageEventToInteractionMessage(event);
+    if (message) messages.push(message);
+  }
+
+  return messages;
+}
+
+export async function deriveBookSessionFromTranscript(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  if (events.length === 0) return null;
+
+  const created = events.find((event) => event.type === "session_created");
+  let bookId = created?.type === "session_created" ? created.bookId : null;
+  let title = created?.type === "session_created" ? created.title : null;
+  const createdAt = created?.type === "session_created"
+    ? created.createdAt
+    : events[0]?.timestamp ?? Date.now();
+  const latestActivityTimestamp = events.reduce((max, event) => {
+    if (event.type === "session_created" || event.type === "session_metadata_updated") {
+      return Math.max(max, event.updatedAt);
+    }
+    return Math.max(max, event.timestamp);
+  }, 0);
+  let updatedAt = created?.type === "session_created"
+    ? created.updatedAt
+    : events[events.length - 1]?.timestamp ?? createdAt;
+  updatedAt = Math.max(updatedAt, latestActivityTimestamp);
+
+  for (const event of events) {
+    if (event.type !== "session_metadata_updated") continue;
+    if ("bookId" in event && event.bookId !== undefined) bookId = event.bookId;
+    if ("title" in event && event.title !== undefined) title = event.title;
+    updatedAt = Math.max(updatedAt, event.updatedAt);
+  }
+
+  const messages = messageEventsToInteractionMessages(committedMessageEvents(events));
+
+  if (title === null) {
+    title = firstUserMessageTitle(messages);
+  }
+
+  return BookSessionSchema.parse({
+    sessionId,
+    bookId,
+    title,
+    messages,
+    draftRounds: [],
+    events: [],
+    createdAt,
+    updatedAt,
+  });
+}

+ 76 - 0
packages/core/src/interaction/session-transcript-schema.ts

@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+export const TranscriptRoleSchema = z.enum(["user", "assistant", "toolResult", "system"]);
+export type TranscriptRole = z.infer<typeof TranscriptRoleSchema>;
+
+const BaseEventSchema = z.object({
+  version: z.literal(1),
+  sessionId: z.string().min(1),
+  seq: z.number().int().nonnegative(),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export const SessionCreatedEventSchema = BaseEventSchema.extend({
+  type: z.literal("session_created"),
+  bookId: z.string().nullable(),
+  title: z.string().nullable().default(null),
+  createdAt: z.number().int().nonnegative(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export const SessionMetadataUpdatedEventSchema = BaseEventSchema.extend({
+  type: z.literal("session_metadata_updated"),
+  bookId: z.string().nullable().optional(),
+  title: z.string().nullable().optional(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export const RequestStartedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_started"),
+  requestId: z.string().min(1),
+  input: z.string(),
+});
+
+export const RequestCommittedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_committed"),
+  requestId: z.string().min(1),
+});
+
+export const RequestFailedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_failed"),
+  requestId: z.string().min(1),
+  error: z.string(),
+});
+
+export const MessageEventSchema = BaseEventSchema.extend({
+  type: z.literal("message"),
+  requestId: z.string().min(1),
+  uuid: z.string().min(1),
+  parentUuid: z.string().min(1).nullable(),
+  role: TranscriptRoleSchema,
+  piTurnIndex: z.number().int().nonnegative().optional(),
+  toolCallId: z.string().min(1).optional(),
+  sourceToolAssistantUuid: z.string().min(1).optional(),
+  legacyDisplay: z.object({
+    thinking: z.string().optional(),
+    toolExecutions: z.array(z.unknown()).optional(),
+  }).optional(),
+  message: z.unknown(),
+});
+
+export const TranscriptEventSchema = z.discriminatedUnion("type", [
+  SessionCreatedEventSchema,
+  SessionMetadataUpdatedEventSchema,
+  RequestStartedEventSchema,
+  RequestCommittedEventSchema,
+  RequestFailedEventSchema,
+  MessageEventSchema,
+]);
+
+export type SessionCreatedEvent = z.infer<typeof SessionCreatedEventSchema>;
+export type SessionMetadataUpdatedEvent = z.infer<typeof SessionMetadataUpdatedEventSchema>;
+export type RequestStartedEvent = z.infer<typeof RequestStartedEventSchema>;
+export type RequestCommittedEvent = z.infer<typeof RequestCommittedEventSchema>;
+export type RequestFailedEvent = z.infer<typeof RequestFailedEventSchema>;
+export type MessageEvent = z.infer<typeof MessageEventSchema>;
+export type TranscriptEvent = z.infer<typeof TranscriptEventSchema>;

+ 189 - 0
packages/core/src/interaction/session-transcript.ts

@@ -0,0 +1,189 @@
+import { randomUUID } from "node:crypto";
+import { appendFile, mkdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import { TranscriptEventSchema, type TranscriptEvent } from "./session-transcript-schema.js";
+import type { TranscriptRole } from "./session-transcript-schema.js";
+
+const SESSIONS_DIR = ".inkos/sessions";
+const appendQueues = new Map<string, Promise<void>>();
+
+export function sessionsDir(projectRoot: string): string {
+  return join(projectRoot, SESSIONS_DIR);
+}
+
+export function transcriptPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.jsonl`);
+}
+
+export function legacyBookSessionPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.json`);
+}
+
+export async function readTranscriptEvents(
+  projectRoot: string,
+  sessionId: string,
+): Promise<TranscriptEvent[]> {
+  let raw: string;
+  try {
+    raw = await readFile(transcriptPath(projectRoot, sessionId), "utf-8");
+  } catch {
+    return [];
+  }
+
+  const events: TranscriptEvent[] = [];
+  for (const line of raw.split(/\r?\n/)) {
+    if (!line.trim()) continue;
+    try {
+      const parsed = TranscriptEventSchema.safeParse(JSON.parse(line));
+      if (parsed.success) events.push(parsed.data);
+    } catch {
+      continue;
+    }
+  }
+
+  return events.sort((a, b) => a.seq - b.seq);
+}
+
+export async function nextTranscriptSeq(projectRoot: string, sessionId: string): Promise<number> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+}
+
+export async function appendTranscriptEvent(
+  projectRoot: string,
+  event: TranscriptEvent,
+): Promise<void> {
+  await appendTranscriptEvents(projectRoot, event.sessionId, () => [event]);
+}
+
+export async function appendTranscriptEvents(
+  projectRoot: string,
+  sessionId: string,
+  buildEvents: (context: {
+    readonly events: ReadonlyArray<TranscriptEvent>;
+    readonly nextSeq: number;
+  }) => ReadonlyArray<TranscriptEvent> | Promise<ReadonlyArray<TranscriptEvent>>,
+): Promise<TranscriptEvent[]> {
+  const key = `${projectRoot}:${sessionId}`;
+  const previous = appendQueues.get(key) ?? Promise.resolve();
+  let result: TranscriptEvent[] = [];
+
+  const next = previous.then(async () => {
+    const events = await readTranscriptEvents(projectRoot, sessionId);
+    const nextSeq = events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+    const built = await buildEvents({ events, nextSeq });
+    result = built.map((event) => TranscriptEventSchema.parse(event));
+    if (result.length === 0) return;
+
+    await mkdir(sessionsDir(projectRoot), { recursive: true });
+    await appendFile(
+      transcriptPath(projectRoot, sessionId),
+      `${result.map((event) => JSON.stringify(event)).join("\n")}\n`,
+      "utf-8",
+    );
+  });
+
+  appendQueues.set(key, next.catch(() => undefined));
+  await next;
+  return result;
+}
+
+function transcriptRoleForMessage(message: AgentMessage): TranscriptRole | null {
+  if (!message || typeof message !== "object" || !("role" in message)) return null;
+  const role = (message as { role?: unknown }).role;
+  return role === "user" || role === "assistant" || role === "toolResult" || role === "system"
+    ? role
+    : null;
+}
+
+function messageTimestamp(message: AgentMessage): number {
+  if (message && typeof message === "object") {
+    const timestamp = (message as { timestamp?: unknown }).timestamp;
+    if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp >= 0) {
+      return Math.floor(timestamp);
+    }
+  }
+  return Date.now();
+}
+
+function toolCallIdForMessage(message: AgentMessage): string | undefined {
+  if (!message || typeof message !== "object") return undefined;
+  if ((message as { role?: unknown }).role === "toolResult") {
+    const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
+    return typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
+  }
+
+  const content = (message as { content?: unknown }).content;
+  if (!Array.isArray(content)) return undefined;
+  const block = content.find(
+    (item): item is { type: "toolCall"; id: string } =>
+      !!item &&
+      typeof item === "object" &&
+      (item as { type?: unknown }).type === "toolCall" &&
+      typeof (item as { id?: unknown }).id === "string",
+  );
+  return block?.id;
+}
+
+export async function appendManualSessionMessages(
+  projectRoot: string,
+  sessionId: string,
+  messages: ReadonlyArray<AgentMessage>,
+  input = "",
+): Promise<void> {
+  const persistedMessages = messages
+    .map((message) => ({ message, role: transcriptRoleForMessage(message) }))
+    .filter((entry): entry is { message: AgentMessage; role: TranscriptRole } => entry.role !== null);
+  if (persistedMessages.length === 0) return;
+
+  const requestId = randomUUID();
+  await appendTranscriptEvents(projectRoot, sessionId, ({ nextSeq }) => {
+    let seq = nextSeq;
+    const events: TranscriptEvent[] = [{
+      type: "request_started",
+      version: 1,
+      sessionId,
+      requestId,
+      seq: seq++,
+      timestamp: Date.now(),
+      input,
+    }];
+
+    let parentUuid: string | null = null;
+    let lastAssistantUuid: string | null = null;
+    for (const { message, role } of persistedMessages) {
+      const uuid = randomUUID();
+      const isToolResult = role === "toolResult";
+      const toolCallId = toolCallIdForMessage(message);
+      events.push({
+        type: "message",
+        version: 1,
+        sessionId,
+        requestId,
+        uuid,
+        parentUuid: isToolResult && lastAssistantUuid ? lastAssistantUuid : parentUuid,
+        seq: seq++,
+        role,
+        timestamp: messageTimestamp(message),
+        ...(toolCallId ? { toolCallId } : {}),
+        ...(isToolResult && lastAssistantUuid
+          ? { sourceToolAssistantUuid: lastAssistantUuid }
+          : {}),
+        message,
+      });
+      if (role === "assistant") lastAssistantUuid = uuid;
+      parentUuid = uuid;
+    }
+
+    events.push({
+      type: "request_committed",
+      version: 1,
+      sessionId,
+      requestId,
+      seq,
+      timestamp: Date.now(),
+    });
+    return events;
+  });
+}

+ 9 - 5
packages/core/src/llm/provider.ts

@@ -31,6 +31,7 @@ export interface StreamProgress {
 export type OnStreamProgress = (progress: StreamProgress) => void;
 export type OnStreamProgress = (progress: StreamProgress) => void;
 
 
 const INKOS_USER_AGENT = "InkOS/1.3.5";
 const INKOS_USER_AGENT = "InkOS/1.3.5";
+const UNKNOWN_MODEL_FALLBACK_MAX_TOKENS = 8192 * 3;
 
 
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
 function mergeUserAgent(headers?: Record<string, string>): Record<string, string> {
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
   return { "User-Agent": INKOS_USER_AGENT, ...(headers ?? {}) };
@@ -104,7 +105,7 @@ export interface LLMClient {
     readonly temperature: number;
     readonly temperature: number;
     /**
     /**
      * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
      * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
-     * 来自 providers bank 的 modelCard.maxOutput(v2.0.0 起)。
+     * 命中模型卡时来自 providers bank 的 modelCard.maxOutput;未知模型走写作兜底预算。
      */
      */
     readonly maxTokens: number;
     readonly maxTokens: number;
     /**
     /**
@@ -150,7 +151,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const _earlyCard = lookupModel(config.service ?? "custom", config.model);
   const _earlyCard = lookupModel(config.service ?? "custom", config.model);
   const defaults = {
   const defaults = {
     temperature: config.temperature ?? 0.7,
     temperature: config.temperature ?? 0.7,
-    maxTokens: _earlyCard?.maxOutput ?? 8192,
+    maxTokens: _earlyCard?.maxOutput ?? UNKNOWN_MODEL_FALLBACK_MAX_TOKENS,
     thinkingBudget: config.thinkingBudget ?? 0,
     thinkingBudget: config.thinkingBudget ?? 0,
     extra: config.extra ?? {},
     extra: config.extra ?? {},
   };
   };
@@ -167,13 +168,16 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const piApi = resolvePiApi(serviceName, config.apiFormat, (inkosProvider?.api ?? preset?.api) as PiApi) as PiApi;
   const piApi = resolvePiApi(serviceName, config.apiFormat, (inkosProvider?.api ?? preset?.api) as PiApi) as PiApi;
   const baseUrl = config.baseUrl || inkosProvider?.baseUrl || preset?.baseUrl || "";
   const baseUrl = config.baseUrl || inkosProvider?.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
   const extraHeaders = config.headers ?? parseEnvHeaders();
-  const compat = resolveProviderCompat(inkosProvider, baseUrl);
+  const compat = piApi === "openai-completions"
+    ? resolveProviderCompat(inkosProvider, baseUrl)
+    : undefined;
 
 
   const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   // pi-ai provider 字段:大多数情况 pi-ai 会按 baseUrl 自动嗅探(openrouter.ai / api.z.ai /
   // pi-ai provider 字段:大多数情况 pi-ai 会按 baseUrl 自动嗅探(openrouter.ai / api.z.ai /
   // api.x.ai / deepseek.com / anthropic.com 等)。这里只列 pi-ai 嗅探不到、需要显式指定的少数情况。
   // api.x.ai / deepseek.com / anthropic.com 等)。这里只列 pi-ai 嗅探不到、需要显式指定的少数情况。
   let piProvider: string;
   let piProvider: string;
-  if (inkosProvider?.id === "zhipu") piProvider = "zai";
+  if (inkosProvider?.id === "google") piProvider = "google";
+  else if (inkosProvider?.id === "zhipu") piProvider = "zai";
   else if (inkosProvider?.id === "openrouter") piProvider = "openrouter";
   else if (inkosProvider?.id === "openrouter") piProvider = "openrouter";
   else if (inkosProvider?.id === "githubCopilot") piProvider = "githubCopilot";
   else if (inkosProvider?.id === "githubCopilot") piProvider = "githubCopilot";
   else if (inkosProvider?.api === "anthropic-messages") piProvider = "anthropic";
   else if (inkosProvider?.api === "anthropic-messages") piProvider = "anthropic";
@@ -193,7 +197,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
     input: ["text"] as ("text" | "image")[],
     input: ["text"] as ("text" | "image")[],
     cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     contextWindow: modelCard?.contextWindowTokens ?? 128_000,
     contextWindow: modelCard?.contextWindowTokens ?? 128_000,
-    maxTokens: modelCard?.maxOutput ?? 8192,
+    maxTokens: modelCard?.maxOutput ?? UNKNOWN_MODEL_FALLBACK_MAX_TOKENS,
     ...(extraHeaders ? { headers: extraHeaders } : {}),
     ...(extraHeaders ? { headers: extraHeaders } : {}),
     ...(compat ? { compat } : {}),
     ...(compat ? { compat } : {}),
   };
   };

+ 1 - 0
packages/core/src/llm/providers/endpoints/deepseek.ts

@@ -21,6 +21,7 @@ export const DEEPSEEK: InkosEndpoint = {
   api: "openai-completions",
   api: "openai-completions",
   baseUrl: "https://api.deepseek.com",
   baseUrl: "https://api.deepseek.com",
   checkModel: "deepseek-v4-flash",
   checkModel: "deepseek-v4-flash",
+  compat: { requiresAssistantAfterToolResult: true },
   temperatureRange: [0, 2],
   temperatureRange: [0, 2],
   defaultTemperature: 1,
   defaultTemperature: 1,
   writingTemperature: 1.5,
   writingTemperature: 1.5,

+ 3 - 3
packages/core/src/llm/providers/endpoints/google.ts

@@ -13,10 +13,10 @@ export const GOOGLE: InkosEndpoint = {
   id: "google",
   id: "google",
   label: "Google Gemini",
   label: "Google Gemini",
   group: "overseas",
   group: "overseas",
-  api: "openai-completions",
-  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+  api: "google-generative-ai",
+  baseUrl: "https://generativelanguage.googleapis.com/v1beta",
+  modelsBaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
   checkModel: "gemini-2.5-flash",
   checkModel: "gemini-2.5-flash",
-  compat: { supportsStore: false },
   temperatureRange: [0, 2],
   temperatureRange: [0, 2],
   defaultTemperature: 1,
   defaultTemperature: 1,
   writingTemperature: 1,
   writingTemperature: 1,

+ 4 - 1
packages/core/src/llm/providers/types.ts

@@ -9,7 +9,8 @@
 export type ApiProtocol =
 export type ApiProtocol =
   | "openai-completions"
   | "openai-completions"
   | "openai-responses"
   | "openai-responses"
-  | "anthropic-messages";
+  | "anthropic-messages"
+  | "google-generative-ai";
 
 
 export type EndpointGroup =
 export type EndpointGroup =
   | "overseas"
   | "overseas"
@@ -54,6 +55,8 @@ export interface ProviderCompat {
   readonly supportsStore?: boolean;
   readonly supportsStore?: boolean;
   readonly supportsSystemRole?: boolean;
   readonly supportsSystemRole?: boolean;
   readonly supportsDeveloperRole?: boolean;
   readonly supportsDeveloperRole?: boolean;
+  /** Some OpenAI-compatible providers reject restored histories ending in toolResult; only those providers get a synthetic assistant bridge during context projection. */
+  readonly requiresAssistantAfterToolResult?: boolean;
 }
 }
 
 
 export interface ProviderTransportDefaults {
 export interface ProviderTransportDefaults {

+ 2 - 0
packages/core/src/llm/service-presets.ts

@@ -85,6 +85,7 @@ export function resolveServiceProviderFamily(service: string): "openai" | "anthr
 }
 }
 
 
 export function resolveServicePiProvider(service: string): string | undefined {
 export function resolveServicePiProvider(service: string): string | undefined {
+  if (service === "google") return "google";
   const preset = resolveServicePreset(service);
   const preset = resolveServicePreset(service);
   if (!preset) return undefined;
   if (!preset) return undefined;
   return preset.piProvider ?? preset.providerFamily;
   return preset.piProvider ?? preset.providerFamily;
@@ -126,6 +127,7 @@ export const SERVICE_TO_PI_PROVIDER: Record<string, string> = Object.fromEntries
     .filter(([service]) => service !== "custom")
     .filter(([service]) => service !== "custom")
     .map(([service, preset]) => [service, preset.piProvider ?? preset.providerFamily]),
     .map(([service, preset]) => [service, preset.piProvider ?? preset.providerFamily]),
 ) as Record<string, string>;
 ) as Record<string, string>;
+SERVICE_TO_PI_PROVIDER.google = "google";
 
 
 export interface ModelInfo {
 export interface ModelInfo {
   readonly id: string;
   readonly id: string;

+ 18 - 0
packages/core/src/llm/service-resolver.ts

@@ -2,6 +2,8 @@ import { getModel } from "@mariozechner/pi-ai";
 import type { Model, Api } from "@mariozechner/pi-ai";
 import type { Model, Api } from "@mariozechner/pi-ai";
 import { resolveServicePiProvider, resolveServicePreset } from "./service-presets.js";
 import { resolveServicePiProvider, resolveServicePreset } from "./service-presets.js";
 import { getServiceApiKey } from "./secrets.js";
 import { getServiceApiKey } from "./secrets.js";
+import { getEndpoint } from "./providers/index.js";
+import type { InkosEndpoint } from "./providers/types.js";
 
 
 export interface ResolvedModel {
 export interface ResolvedModel {
   model: Model<Api>;
   model: Model<Api>;
@@ -11,6 +13,17 @@ export interface ResolvedModel {
   temperatureHint?: string;
   temperatureHint?: string;
 }
 }
 
 
+function resolveProviderCompat(
+  provider: InkosEndpoint | undefined,
+  baseUrl: string,
+): Record<string, unknown> | undefined {
+  const compat = {
+    ...(provider?.compat ?? {}),
+    ...(baseUrl.includes("generativelanguage.googleapis.com") ? { supportsStore: false } : {}),
+  };
+  return Object.keys(compat).length > 0 ? compat : undefined;
+}
+
 export async function resolveServiceModel(
 export async function resolveServiceModel(
   service: string,
   service: string,
   modelId: string,
   modelId: string,
@@ -29,6 +42,7 @@ export async function resolveServiceModel(
   // Determine pi-ai provider
   // Determine pi-ai provider
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const baseService = service.startsWith("custom:") ? "custom" : service;
   const preset = resolveServicePreset(baseService);
   const preset = resolveServicePreset(baseService);
+  const endpoint = getEndpoint(baseService);
   const piProvider = resolveServicePiProvider(baseService) ?? "openai";
   const piProvider = resolveServicePiProvider(baseService) ?? "openai";
   const apiType = service.startsWith("custom:")
   const apiType = service.startsWith("custom:")
     ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
     ? (customApiFormat === "responses" ? "openai-responses" : "openai-completions")
@@ -38,6 +52,9 @@ export async function resolveServiceModel(
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
   // Get pi-ai Model — may return undefined for model IDs not in the built-in registry
   const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
   const piModel = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
   const effectiveBaseUrl = configuredBaseUrl || piModel?.baseUrl || "";
   const effectiveBaseUrl = configuredBaseUrl || piModel?.baseUrl || "";
+  const compat = apiType === "openai-completions"
+    ? resolveProviderCompat(endpoint, effectiveBaseUrl)
+    : undefined;
 
 
   if (!effectiveBaseUrl) {
   if (!effectiveBaseUrl) {
     throw new Error(
     throw new Error(
@@ -56,6 +73,7 @@ export async function resolveServiceModel(
     cost: piModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     cost: piModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
     contextWindow: piModel?.contextWindow ?? 0,
     contextWindow: piModel?.contextWindow ?? 0,
     maxTokens: piModel?.maxTokens ?? 16384,
     maxTokens: piModel?.maxTokens ?? 16384,
+    ...(compat ? { compat: compat as Model<Api>["compat"] } : {}),
   };
   };
 
 
   return {
   return {

+ 205 - 4
packages/studio/src/api/server.test.ts

@@ -27,6 +27,7 @@ const createAndPersistBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
+const appendManualSessionMessagesMock = vi.fn();
 const renameBookSessionMock = vi.fn();
 const renameBookSessionMock = vi.fn();
 const deleteBookSessionMock = vi.fn();
 const deleteBookSessionMock = vi.fn();
 const migrateBookSessionMock = vi.fn();
 const migrateBookSessionMock = vi.fn();
@@ -130,8 +131,8 @@ vi.mock("@actalk/inkos-core", () => {
       return [];
       return [];
     }
     }
 
 
-    async loadBookConfig(): Promise<never> {
-      return await loadBookConfigMock() as never;
+    async loadBookConfig(bookId?: string): Promise<never> {
+      return await loadBookConfigMock(bookId) as never;
     }
     }
 
 
     async loadChapterIndex(bookId: string): Promise<[]> {
     async loadChapterIndex(bookId: string): Promise<[]> {
@@ -146,7 +147,7 @@ vi.mock("@actalk/inkos-core", () => {
       return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
       return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
     }
     }
 
 
-    async getNextChapterNumber(): Promise<number> {
+    async getNextChapterNumber(_bookId?: string): Promise<number> {
       return 1;
       return 1;
     }
     }
 
 
@@ -210,6 +211,7 @@ vi.mock("@actalk/inkos-core", () => {
     loadBookSession: loadBookSessionMock,
     loadBookSession: loadBookSessionMock,
     persistBookSession: persistBookSessionMock,
     persistBookSession: persistBookSessionMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
+    appendManualSessionMessages: appendManualSessionMessagesMock,
     isNewLayoutBook: vi.fn(async () => false),
     isNewLayoutBook: vi.fn(async () => false),
     renameBookSession: renameBookSessionMock,
     renameBookSession: renameBookSessionMock,
     deleteBookSession: deleteBookSessionMock,
     deleteBookSession: deleteBookSessionMock,
@@ -380,6 +382,7 @@ describe("createStudioServer daemon lifecycle", () => {
     loadBookSessionMock.mockReset();
     loadBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     appendBookSessionMessageMock.mockReset();
     appendBookSessionMessageMock.mockReset();
+    appendManualSessionMessagesMock.mockReset();
     renameBookSessionMock.mockReset();
     renameBookSessionMock.mockReset();
     deleteBookSessionMock.mockReset();
     deleteBookSessionMock.mockReset();
     migrateBookSessionMock.mockReset();
     migrateBookSessionMock.mockReset();
@@ -410,6 +413,7 @@ describe("createStudioServer daemon lifecycle", () => {
     appendBookSessionMessageMock.mockImplementation(
     appendBookSessionMessageMock.mockImplementation(
       (session: unknown, _msg: unknown) => session,
       (session: unknown, _msg: unknown) => session,
     );
     );
+    appendManualSessionMessagesMock.mockResolvedValue(undefined);
     renameBookSessionMock.mockResolvedValue(null);
     renameBookSessionMock.mockResolvedValue(null);
     deleteBookSessionMock.mockResolvedValue(undefined);
     deleteBookSessionMock.mockResolvedValue(undefined);
     migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
     migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
@@ -1759,10 +1763,76 @@ describe("createStudioServer daemon lifecycle", () => {
         projectRoot: root,
         projectRoot: root,
       }),
       }),
       "continue",
       "continue",
-      expect.any(Array),
     );
     );
   });
   });
 
 
+  it("does not override system file read policy from Studio agent API by default", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(200);
+    const agentConfig = runAgentSessionMock.mock.calls.at(-1)?.[0] as Record<string, unknown>;
+    expect("allowSystemFileRead" in agentConfig).toBe(false);
+  });
+
+  it("does not append or persist legacy BookSession messages after agent success", async () => {
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "Agent response.",
+      messages: [
+        { role: "user", content: "continue", timestamp: 1 },
+        { role: "assistant", content: [{ type: "text", text: "Agent response." }], timestamp: 2 },
+      ],
+    });
+    loadBookSessionMock
+      .mockResolvedValueOnce({
+        sessionId: "agent-session-1",
+        bookId: "demo-book",
+        title: null,
+        messages: [],
+        events: [],
+        draftRounds: [],
+        createdAt: 1,
+        updatedAt: 1,
+      })
+      .mockResolvedValueOnce({
+        sessionId: "agent-session-1",
+        bookId: "demo-book",
+        title: "continue",
+        messages: [
+          { role: "user", content: "continue", timestamp: 1 },
+          { role: "assistant", content: "Agent response.", timestamp: 2 },
+        ],
+        events: [],
+        draftRounds: [],
+        createdAt: 1,
+        updatedAt: 2,
+      });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(appendBookSessionMessageMock).not.toHaveBeenCalled();
+    expect(persistBookSessionMock).not.toHaveBeenCalled();
+    expect(runAgentSessionMock).toHaveBeenCalledWith(
+      expect.objectContaining({ sessionId: "agent-session-1" }),
+      "continue",
+    );
+    expect(loadBookSessionMock).toHaveBeenCalledTimes(2);
+  });
+
   it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
   it("allows /api/agent to use explicit service+model when Studio config has no defaultModel", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
       ...projectConfig,
@@ -1898,6 +1968,62 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     });
   });
   });
 
 
+  it("returns the agent final assistant error without replacing it with an empty-response probe", async () => {
+    const upstreamError = "400 The `reasoning_content` in the thinking mode must be passed back to the API.";
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "",
+      errorMessage: upstreamError,
+      messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: upstreamError }],
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(502);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "AGENT_LLM_ERROR",
+        message: upstreamError,
+      },
+      response: upstreamError,
+    });
+    expect(chatCompletionMock).not.toHaveBeenCalled();
+  });
+
+  it("returns malformed Gemini function-call errors without replacing them with an empty-response probe", async () => {
+    const upstreamError = "Provider finish_reason: function_call_filter: MALFORMED_FUNCTION_CALL";
+    runAgentSessionMock.mockResolvedValueOnce({
+      responseText: "",
+      errorMessage: upstreamError,
+      messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: upstreamError }],
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "nihao", activeBookId: "demo-book", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(502);
+    await expect(response.json()).resolves.toEqual({
+      error: {
+        code: "AGENT_LLM_ERROR",
+        message: upstreamError,
+      },
+      response: upstreamError,
+    });
+    expect(chatCompletionMock).not.toHaveBeenCalled();
+  });
+
   it("falls back to plain chat when the tool-agent returns empty text", async () => {
   it("falls back to plain chat when the tool-agent returns empty text", async () => {
     runAgentSessionMock.mockResolvedValueOnce({
     runAgentSessionMock.mockResolvedValueOnce({
       responseText: "",
       responseText: "",
@@ -1924,6 +2050,81 @@ describe("createStudioServer daemon lifecycle", () => {
     });
     });
   });
   });
 
 
+  it("migrates and exposes a book created by architect even when the final agent text is empty", async () => {
+    const orphanSession = {
+      sessionId: "agent-session-1",
+      bookId: null,
+      title: null,
+      messages: [],
+      events: [],
+      draftRounds: [],
+      createdAt: 1,
+      updatedAt: 1,
+    };
+    loadBookSessionMock.mockResolvedValue(orphanSession);
+    appendBookSessionMessageMock.mockImplementation((session: unknown) => session);
+    migrateBookSessionMock.mockResolvedValue({
+      ...orphanSession,
+      bookId: "new-book",
+    });
+    loadBookConfigMock.mockImplementation(async (bookId?: string) => ({
+      id: bookId ?? "new-book",
+      title: "New Book",
+      platform: "qidian",
+      genre: "urban",
+      status: "outlining",
+      targetChapters: 100,
+      chapterWordCount: 3000,
+      createdAt: "2026-04-12T00:00:00.000Z",
+      updatedAt: "2026-04-12T00:00:00.000Z",
+    }));
+    runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
+      config.onEvent?.({
+        type: "tool_execution_start",
+        toolCallId: "tool-1",
+        toolName: "sub_agent",
+        args: { agent: "architect", title: "New Book" },
+      });
+      config.onEvent?.({
+        type: "tool_execution_end",
+        toolCallId: "tool-1",
+        toolName: "sub_agent",
+        isError: false,
+        result: {
+          content: [{ type: "text", text: "Book created." }],
+          details: { kind: "book_created", bookId: "new-book", title: "New Book" },
+        },
+      });
+      return {
+        responseText: "",
+        messages: [{ role: "user", content: "/new New Book" }],
+      };
+    });
+    chatCompletionMock.mockResolvedValueOnce({
+      content: "建书完成。",
+      usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ instruction: "写一本都市商战", sessionId: "agent-session-1" }),
+    });
+
+    expect(response.status).toBe(200);
+    expect(migrateBookSessionMock).toHaveBeenCalledWith(root, "agent-session-1", "new-book");
+    await expect(response.json()).resolves.toMatchObject({
+      response: "建书完成。",
+      session: {
+        sessionId: "agent-session-1",
+        activeBookId: "new-book",
+      },
+    });
+  });
+
   it("rejects /api/v1/agent requests without sessionId", async () => {
   it("rejects /api/v1/agent requests without sessionId", async () => {
     const { createStudioServer } = await import("./server.js");
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
     const app = createStudioServer(cloneProjectConfig() as never, root);

+ 170 - 77
packages/studio/src/api/server.ts

@@ -15,8 +15,7 @@ import {
   resolveSessionActiveBook,
   resolveSessionActiveBook,
   listBookSessions,
   listBookSessions,
   loadBookSession,
   loadBookSession,
-  persistBookSession,
-  appendBookSessionMessage,
+  appendManualSessionMessages,
   createAndPersistBookSession,
   createAndPersistBookSession,
   renameBookSession,
   renameBookSession,
   deleteBookSession,
   deleteBookSession,
@@ -43,7 +42,7 @@ import {
   type LogEntry,
   type LogEntry,
 } from "@actalk/inkos-core";
 } from "@actalk/inkos-core";
 import { access, readFile, readdir, writeFile } from "node:fs/promises";
 import { access, readFile, readdir, writeFile } from "node:fs/promises";
-import { join } from "node:path";
+import { isAbsolute, join, relative, resolve } from "node:path";
 import { isSafeBookId } from "./safety.js";
 import { isSafeBookId } from "./safety.js";
 import { ApiError } from "./errors.js";
 import { ApiError } from "./errors.js";
 import { buildStudioBookConfig } from "./book-create.js";
 import { buildStudioBookConfig } from "./book-create.js";
@@ -136,12 +135,22 @@ interface CollectedToolExec {
   status: "running" | "completed" | "error";
   status: "running" | "completed" | "error";
   args?: Record<string, unknown>;
   args?: Record<string, unknown>;
   result?: string;
   result?: string;
+  details?: unknown;
   error?: string;
   error?: string;
   stages?: Array<{ label: string; status: "pending" | "completed" }>;
   stages?: Array<{ label: string; status: "pending" | "completed" }>;
   startedAt: number;
   startedAt: number;
   completedAt?: number;
   completedAt?: number;
 }
 }
 
 
+interface StudioBookListSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+  readonly [key: string]: unknown;
+}
+
 // --- Event bus for SSE ---
 // --- Event bus for SSE ---
 
 
 type EventHandler = (event: string, data: unknown) => void;
 type EventHandler = (event: string, data: unknown) => void;
@@ -194,6 +203,50 @@ function broadcast(event: string, data: unknown): void {
   }
   }
 }
 }
 
 
+function deriveBookIdFromTitle(title: string): string {
+  return title
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+    .replace(/-+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    .slice(0, 30);
+}
+
+function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string | null {
+  if (!args || args.agent !== "architect" || args.revise === true) return null;
+  if (typeof args.bookId === "string" && args.bookId.trim()) return args.bookId.trim();
+  if (typeof args.title === "string" && args.title.trim()) {
+    return deriveBookIdFromTitle(args.title) || null;
+  }
+  return null;
+}
+
+function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
+  for (let i = execs.length - 1; i >= 0; i -= 1) {
+    const exec = execs[i];
+    if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
+
+    const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
+    if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
+      return details.bookId.trim();
+    }
+
+    const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
+    if (fromArgs) return fromArgs;
+  }
+  return null;
+}
+
+async function loadStudioBookListSummary(
+  state: StateManager,
+  bookId: string,
+): Promise<StudioBookListSummary> {
+  const book = await state.loadBookConfig(bookId);
+  const nextChapter = await state.getNextChapterNumber(bookId);
+  return { ...book, chaptersWritten: nextChapter - 1 };
+}
+
 function isCustomServiceId(serviceId: string): boolean {
 function isCustomServiceId(serviceId: string): boolean {
   return serviceId === "custom" || serviceId.startsWith("custom:");
   return serviceId === "custom" || serviceId.startsWith("custom:");
 }
 }
@@ -728,13 +781,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
   app.get("/api/v1/books", async (c) => {
   app.get("/api/v1/books", async (c) => {
     const bookIds = await state.listBooks();
     const bookIds = await state.listBooks();
-    const books = await Promise.all(
-      bookIds.map(async (id) => {
-        const book = await state.loadBookConfig(id);
-        const nextChapter = await state.getNextChapterNumber(id);
-        return { ...book, chaptersWritten: nextChapter - 1 };
-      }),
-    );
+    const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
     return c.json({ books });
     return c.json({ books });
   });
   });
 
 
@@ -811,13 +858,14 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       },
       },
       tools,
       tools,
     }).then(
     }).then(
-      (result: {
+      async (result: {
         readonly session: { readonly activeBookId?: string };
         readonly session: { readonly activeBookId?: string };
         readonly details?: Readonly<Record<string, unknown>>;
         readonly details?: Readonly<Record<string, unknown>>;
       }) => {
       }) => {
         const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
         const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
+        const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
         bookCreateStatus.delete(createdBookId);
         bookCreateStatus.delete(createdBookId);
-        broadcast("book:created", { bookId: createdBookId });
+        broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
       },
       },
       (e: unknown) => {
       (e: unknown) => {
         const error = e instanceof Error ? e.message : String(e);
         const error = e instanceof Error ? e.message : String(e);
@@ -925,7 +973,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
    */
    */
   function resolveTruthFilePath(bookDir: string, file: string): string | null {
   function resolveTruthFilePath(bookDir: string, file: string): string | null {
     // Reject absolute paths, traversal, null bytes outright.
     // Reject absolute paths, traversal, null bytes outright.
-    if (!file || file.includes("\0") || file.startsWith("/") || file.includes("..")) {
+    if (!file || file.includes("\0") || isAbsolute(file) || file.includes("..")) {
       return null;
       return null;
     }
     }
 
 
@@ -940,12 +988,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
     if (!allowed) return null;
     if (!allowed) return null;
 
 
-    const storyDir = join(bookDir, "story");
-    const resolved = join(storyDir, file);
-    // Path safety: resolved absolute path must sit inside storyDir. `join`
-    // collapses any `..` segments, so compare against the storyDir prefix.
-    const storyPrefix = storyDir.endsWith("/") ? storyDir : storyDir + "/";
-    if (!resolved.startsWith(storyPrefix) && resolved !== storyDir) {
+    const storyDir = resolve(bookDir, "story");
+    const resolved = resolve(storyDir, file);
+    const relativePath = relative(storyDir, resolved);
+    if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
       return null;
       return null;
     }
     }
     return resolved;
     return resolved;
@@ -1090,6 +1136,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         stream.writeSSE({ event, data: JSON.stringify(data) });
         stream.writeSSE({ event, data: JSON.stringify(data) });
       };
       };
       subscribers.add(handler);
       subscribers.add(handler);
+      await stream.writeSSE({ event: "ping", data: "" });
 
 
       // Keep alive
       // Keep alive
       const keepAlive = setInterval(() => {
       const keepAlive = setInterval(() => {
@@ -1623,11 +1670,18 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
       }
       let bookSession = loadedBookSession;
       let bookSession = loadedBookSession;
       const streamSessionId = loadedBookSession.sessionId;
       const streamSessionId = loadedBookSession.sessionId;
-
-      // Build initial message context from persisted session
-      const initialMessages = bookSession.messages
-        .filter((m) => m.role === "user" || m.role === "assistant")
-        .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
+      const titleBeforeRun = bookSession.title;
+      let sessionTitleBroadcasted = false;
+      const refreshBookSessionFromTranscript = async (): Promise<void> => {
+        const refreshed = await loadBookSession(root, bookSession.sessionId);
+        if (refreshed) {
+          bookSession = refreshed;
+        }
+        if (!sessionTitleBroadcasted && titleBeforeRun === null && bookSession.title) {
+          broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
+          sessionTitleBroadcasted = true;
+        }
+      };
 
 
       // Resolve model — multi-service resolution
       // Resolve model — multi-service resolution
       let resolvedModel: ResolvedModel["model"] | undefined;
       let resolvedModel: ResolvedModel["model"] | undefined;
@@ -1780,6 +1834,17 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
                 startedAt: Date.now(),
                 startedAt: Date.now(),
               });
               });
 
 
+              if (!activeBookId && event.toolName === "sub_agent" && agent === "architect") {
+                const bookId = resolveArchitectBookIdFromArgs(args);
+                if (bookId) {
+                  const title = typeof args?.title === "string" && args.title.trim()
+                    ? args.title.trim()
+                    : bookId;
+                  bookCreateStatus.set(bookId, { status: "creating" });
+                  broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
+                }
+              }
+
               broadcast("tool:start", {
               broadcast("tool:start", {
                 sessionId: streamSessionId,
                 sessionId: streamSessionId,
                 id: event.toolCallId,
                 id: event.toolCallId,
@@ -1803,6 +1868,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
                 exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
                 exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
                 if (event.isError) exec.error = extractToolError(event.result);
                 if (event.isError) exec.error = extractToolError(event.result);
                 else exec.result = summarizeResult(event.result);
                 else exec.result = summarizeResult(event.result);
+                exec.details = (event.result as { details?: unknown } | undefined)?.details;
+                if (
+                  event.isError &&
+                  !activeBookId &&
+                  exec.tool === "sub_agent" &&
+                  exec.agent === "architect"
+                ) {
+                  const bookId = resolveArchitectBookIdFromArgs(exec.args);
+                  if (bookId) {
+                    const error = exec.error ?? "Book creation failed";
+                    bookCreateStatus.set(bookId, { status: "error", error });
+                    broadcast("book:error", { bookId, sessionId: streamSessionId, error });
+                  }
+                }
               }
               }
               broadcast("tool:end", {
               broadcast("tool:end", {
                 sessionId: streamSessionId,
                 sessionId: streamSessionId,
@@ -1815,37 +1894,48 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           },
           },
         },
         },
         instruction,
         instruction,
-        initialMessages,
       );
       );
 
 
-      // Persist user + assistant messages to BookSession
-      bookSession = appendBookSessionMessage(bookSession, {
-        role: "user",
-        content: instruction,
-        timestamp: Date.now(),
-      });
-      // 第一条用户消息就是 session 的标题:如果 title 还是 null,用消息内容(单行、≤20字)写入。
-      // 后续消息不覆盖;用户手动改名通过 renameBookSession 覆盖。
-      if (bookSession.title === null) {
-        const oneLine = instruction.trim().replace(/\s+/g, " ");
-        const title = oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
-        if (title) {
-          bookSession = { ...bookSession, title };
-          broadcast("session:title", { sessionId: bookSession.sessionId, title });
+      let broadcastedCreatedBookId: string | null = null;
+      const finalizeCreatedBook = async (): Promise<string | null> => {
+        if (activeBookId) return null;
+        const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
+        if (!createdBookId) return null;
+        if (broadcastedCreatedBookId === createdBookId) return createdBookId;
+
+        try {
+          const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
+          if (migratedSession) {
+            bookSession = migratedSession;
+          }
+        } catch (e) {
+          if (!(e instanceof SessionAlreadyMigratedError)) {
+            throw e;
+          }
         }
         }
-      }
-      if (result.responseText) {
-        const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
-        const thinking = lastAssistant?.thinking;
-        bookSession = appendBookSessionMessage(bookSession, {
-          role: "assistant",
-          content: result.responseText,
-          ...(thinking ? { thinking } : {}),
-          ...(collectedToolExecs.length > 0 ? { toolExecutions: collectedToolExecs } : {}),
-          timestamp: Date.now() + 1,
+
+        const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
+        bookCreateStatus.delete(createdBookId);
+        broadcast("book:created", {
+          bookId: createdBookId,
+          sessionId: bookSession.sessionId,
+          ...(book ? { book } : {}),
         });
         });
-      }
+        broadcastedCreatedBookId = createdBookId;
+        return createdBookId;
+      };
+
       if (!result.responseText) {
       if (!result.responseText) {
+        if (result.errorMessage) {
+          if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
+            await finalizeCreatedBook();
+          }
+          return c.json({
+            error: { code: "AGENT_LLM_ERROR", message: result.errorMessage },
+            response: result.errorMessage,
+          }, 502);
+        }
+
         try {
         try {
           const fallbackClient = createLLMClient({
           const fallbackClient = createLLMClient({
             ...config.llm,
             ...config.llm,
@@ -1866,15 +1956,31 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             { maxTokens: 256 },
             { maxTokens: 256 },
           );
           );
           if (fallback.content?.trim()) {
           if (fallback.content?.trim()) {
-            bookSession = appendBookSessionMessage(bookSession, {
+            await appendManualSessionMessages(root, bookSession.sessionId, [{
               role: "assistant",
               role: "assistant",
-              content: fallback.content,
-              timestamp: Date.now() + 1,
-            });
-            await persistBookSession(root, bookSession);
+              content: [{ type: "text", text: fallback.content }],
+              api: "anthropic-messages",
+              provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
+              model: reqModel ?? config.llm.model,
+              usage: {
+                input: 0,
+                output: 0,
+                cacheRead: 0,
+                cacheWrite: 0,
+                totalTokens: 0,
+                cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+              },
+              stopReason: "stop",
+              timestamp: Date.now(),
+            }], instruction);
+            await refreshBookSessionFromTranscript();
+            const createdBookId = await finalizeCreatedBook();
             return c.json({
             return c.json({
               response: fallback.content,
               response: fallback.content,
-              session: { sessionId: bookSession.sessionId },
+              session: {
+                sessionId: bookSession.sessionId,
+                ...(createdBookId ? { activeBookId: createdBookId } : {}),
+              },
             });
             });
           }
           }
         } catch {
         } catch {
@@ -1899,6 +2005,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           );
           );
         } catch (probeError) {
         } catch (probeError) {
           const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
           const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
+          if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
+            await finalizeCreatedBook();
+          }
           return c.json({
           return c.json({
             error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
             error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
             response: probeMessage,
             response: probeMessage,
@@ -1906,35 +2015,19 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         }
         }
 
 
         const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
         const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
+        if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
+          await finalizeCreatedBook();
+        }
         return c.json({
         return c.json({
           error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
           error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
           response: emptyMessage,
           response: emptyMessage,
         }, 502);
         }, 502);
       }
       }
-      await persistBookSession(root, bookSession);
+      await refreshBookSessionFromTranscript();
+      await finalizeCreatedBook();
 
 
       broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
       broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
 
 
-      // If a sub_agent created a new book during this session, broadcast book:created
-      // so the sidebar refreshes.
-      if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) {
-        const books = await state.listBooks();
-        const latestBook = books.at(-1);
-        if (latestBook) {
-          try {
-            const migratedSession = await migrateBookSession(root, bookSession.sessionId, latestBook);
-            if (migratedSession) {
-              bookSession = migratedSession;
-            }
-          } catch (e) {
-            if (!(e instanceof SessionAlreadyMigratedError)) {
-              throw e;
-            }
-          }
-          broadcast("book:created", { bookId: latestBook, sessionId: bookSession.sessionId });
-        }
-      }
-
       return c.json({
       return c.json({
         response: result.responseText,
         response: result.responseText,
         session: {
         session: {

+ 13 - 3
packages/studio/src/components/Sidebar.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useMemo, useState } from "react";
 import { useEffect, useMemo, useState } from "react";
 import { useApi } from "../hooks/use-api";
 import { useApi } from "../hooks/use-api";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { SSEMessage } from "../hooks/use-sse";
-import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
+import { applyBookCollectionEvent, shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import { useChatStore } from "../store/chat";
 import { useChatStore } from "../store/chat";
 import { ConfirmDialog } from "./ConfirmDialog";
 import { ConfirmDialog } from "./ConfirmDialog";
@@ -65,7 +65,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
   sse: { messages: ReadonlyArray<SSEMessage> };
   sse: { messages: ReadonlyArray<SSEMessage> };
   t: TFunction;
   t: TFunction;
 }) {
 }) {
-  const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const { data, refetch: refetchBooks, mutate: mutateBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
   const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
   const sessions = useChatStore((s) => s.sessions);
   const sessions = useChatStore((s) => s.sessions);
   const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
   const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
@@ -88,12 +88,22 @@ export function Sidebar({ nav, activePage, sse, t }: {
     const recent = sse.messages.at(-1);
     const recent = sse.messages.at(-1);
     if (!recent) return;
     if (!recent) return;
     if (shouldRefetchBookCollections(recent)) {
     if (shouldRefetchBookCollections(recent)) {
+      let appliedIncrementally = false;
+      mutateBooks((current) => {
+        const updatedBooks = applyBookCollectionEvent(current?.books ?? [], recent);
+        if (!updatedBooks) return current;
+        appliedIncrementally = true;
+        return { books: updatedBooks };
+      });
+      if (appliedIncrementally) {
+        return;
+      }
       refetchBooks();
       refetchBooks();
     }
     }
     if (shouldRefetchDaemonStatus(recent)) {
     if (shouldRefetchDaemonStatus(recent)) {
       refetchDaemon();
       refetchDaemon();
     }
     }
-  }, [refetchBooks, refetchDaemon, sse.messages]);
+  }, [mutateBooks, refetchBooks, refetchDaemon, sse.messages]);
 
 
   // bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
   // bookDataVersion 变化(外部数据信号)时才重拉当前已展开书的 session 列表;
   // 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。
   // 展开/折叠本身不触发请求(展开由 toggleBook 驱动,已带"首次加载"判断)。

+ 1 - 1
packages/studio/src/hooks/use-api.ts

@@ -164,7 +164,7 @@ export function useApi<T>(path: string) {
     };
     };
   }, [path, refetch]);
   }, [path, refetch]);
 
 
-  return { data, loading, error, refetch };
+  return { data, loading, error, refetch, mutate: setData };
 }
 }
 
 
 export async function postApi<T>(path: string, body?: unknown): Promise<T> {
 export async function postApi<T>(path: string, body?: unknown): Promise<T> {

+ 21 - 0
packages/studio/src/hooks/use-book-activity.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest";
 import { describe, expect, it } from "vitest";
 import type { SSEMessage } from "./use-sse";
 import type { SSEMessage } from "./use-sse";
 import {
 import {
+  applyBookCollectionEvent,
   deriveActiveBookIds,
   deriveActiveBookIds,
   deriveBookActivity,
   deriveBookActivity,
   shouldRefetchBookCollections,
   shouldRefetchBookCollections,
@@ -106,3 +107,23 @@ describe("shouldRefetchDaemonStatus", () => {
     expect(shouldRefetchDaemonStatus(msg("daemon:chapter", {}, 1))).toBe(false);
     expect(shouldRefetchDaemonStatus(msg("daemon:chapter", {}, 1))).toBe(false);
   });
   });
 });
 });
+
+describe("applyBookCollectionEvent", () => {
+  it("upserts a created book from the event payload without requiring a refetch", () => {
+    const books = [
+      { id: "alpha", title: "Alpha", genre: "urban", status: "active", chaptersWritten: 3 },
+    ];
+
+    expect(applyBookCollectionEvent(books, msg("book:created", {
+      bookId: "beta",
+      book: { id: "beta", title: "Beta", genre: "xuanhuan", status: "outlining", chaptersWritten: 0 },
+    }, 1))).toEqual([
+      { id: "alpha", title: "Alpha", genre: "urban", status: "active", chaptersWritten: 3 },
+      { id: "beta", title: "Beta", genre: "xuanhuan", status: "outlining", chaptersWritten: 0 },
+    ]);
+  });
+
+  it("returns null when a collection event lacks enough data for incremental update", () => {
+    expect(applyBookCollectionEvent([], msg("book:created", { bookId: "beta" }, 1))).toBeNull();
+  });
+});

+ 56 - 0
packages/studio/src/hooks/use-book-activity.ts

@@ -43,11 +43,42 @@ export interface BookActivity {
   readonly lastError: string | null;
   readonly lastError: string | null;
 }
 }
 
 
+export interface SidebarBookSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+}
+
 function getBookId(message: SSEMessage): string | null {
 function getBookId(message: SSEMessage): string | null {
   const data = message.data as { bookId?: unknown } | null;
   const data = message.data as { bookId?: unknown } | null;
   return typeof data?.bookId === "string" ? data.bookId : null;
   return typeof data?.bookId === "string" ? data.bookId : null;
 }
 }
 
 
+function getBookSummary(message: SSEMessage): SidebarBookSummary | null {
+  const data = message.data as { book?: unknown } | null;
+  const book = data?.book;
+  if (!book || typeof book !== "object") return null;
+  const candidate = book as Partial<SidebarBookSummary>;
+  if (
+    typeof candidate.id !== "string" ||
+    typeof candidate.title !== "string" ||
+    typeof candidate.genre !== "string" ||
+    typeof candidate.status !== "string" ||
+    typeof candidate.chaptersWritten !== "number"
+  ) {
+    return null;
+  }
+  return {
+    id: candidate.id,
+    title: candidate.title,
+    genre: candidate.genre,
+    status: candidate.status,
+    chaptersWritten: candidate.chaptersWritten,
+  };
+}
+
 export function deriveActiveBookIds(messages: ReadonlyArray<SSEMessage>): ReadonlySet<string> {
 export function deriveActiveBookIds(messages: ReadonlyArray<SSEMessage>): ReadonlySet<string> {
   const active = new Set<string>();
   const active = new Set<string>();
 
 
@@ -122,3 +153,28 @@ export function shouldRefetchBookCollections(message: SSEMessage | undefined): b
 export function shouldRefetchDaemonStatus(message: SSEMessage | undefined): boolean {
 export function shouldRefetchDaemonStatus(message: SSEMessage | undefined): boolean {
   return Boolean(message && DAEMON_STATUS_REFRESH_EVENTS.has(message.event));
   return Boolean(message && DAEMON_STATUS_REFRESH_EVENTS.has(message.event));
 }
 }
+
+export function applyBookCollectionEvent(
+  books: ReadonlyArray<SidebarBookSummary>,
+  message: SSEMessage | undefined,
+): ReadonlyArray<SidebarBookSummary> | null {
+  if (!message) return null;
+
+  if (message.event === "book:created") {
+    const book = getBookSummary(message);
+    if (!book) return null;
+    const existingIndex = books.findIndex((candidate) => candidate.id === book.id);
+    if (existingIndex < 0) {
+      return [...books, book];
+    }
+    return books.map((candidate, index) => index === existingIndex ? book : candidate);
+  }
+
+  if (message.event === "book:deleted") {
+    const bookId = getBookId(message);
+    if (!bookId) return null;
+    return books.filter((book) => book.id !== bookId);
+  }
+
+  return null;
+}

+ 1 - 1
packages/studio/src/pages/ChatPage.tsx

@@ -213,7 +213,7 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
       {/* Message scroll area */}
       {/* Message scroll area */}
       <div
       <div
         ref={scrollRef}
         ref={scrollRef}
-        className="flex-1 overflow-y-auto px-4 py-6"
+        className="chat-message-scroll flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6"
       >
       >
         {messages.length === 0 && !loading ? (
         {messages.length === 0 && !loading ? (
           <div className="h-full flex flex-col items-center justify-center text-center select-none">
           <div className="h-full flex flex-col items-center justify-center text-center select-none">

+ 1 - 0
packages/studio/src/store/chat/types.ts

@@ -53,6 +53,7 @@ export interface SessionMessage {
   readonly role: "user" | "assistant" | "system";
   readonly role: "user" | "assistant" | "system";
   readonly content: string;
   readonly content: string;
   readonly thinking?: string;
   readonly thinking?: string;
+  readonly toolExecutions?: ReadonlyArray<ToolExecution>;
   readonly timestamp: number;
   readonly timestamp: number;
 }
 }