Ver Fonte

fix: project agent history by model protocol

fanghanjun há 6 meses atrás
pai
commit
ec136b62d0

+ 287 - 3
packages/core/src/__tests__/agent-session.test.ts

@@ -73,7 +73,19 @@ vi.mock("@mariozechner/pi-ai", async () => {
     const timestamp = Date.now();
     const timestamp = Date.now();
     const message = last?.role === "toolResult"
     const message = last?.role === "toolResult"
       ? assistant([{ type: "text", text: "ok" }], timestamp)
       ? assistant([{ type: "text", text: "ok" }], timestamp)
-      : prompt === "think"
+      : 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([
         ? assistant([
             { type: "thinking", thinking: "raw thought", thinkingSignature: "sig-1" },
             { type: "thinking", thinking: "raw thought", thinkingSignature: "sig-1" },
             { type: "text", text: "ok" },
             { type: "text", text: "ok" },
@@ -117,7 +129,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
 });
 });
 
 
 import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
 import { runAgentSession, evictAgentCache } from "../agent/agent-session.js";
-import { readTranscriptEvents } from "../interaction/session-transcript.js";
+import { appendTranscriptEvent, readTranscriptEvents } from "../interaction/session-transcript.js";
 
 
 describe("runAgentSession cache — bookId switch", () => {
 describe("runAgentSession cache — bookId switch", () => {
   let projectRoot: string;
   let projectRoot: string;
@@ -219,6 +231,55 @@ describe("runAgentSession cache — bookId switch", () => {
     expect(agentInstances).toHaveLength(1);
     expect(agentInstances).toHaveLength(1);
   });
   });
 
 
+  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("enables system file read by default for the session read tool", async () => {
   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 model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
     const pipeline = {} as any;
     const pipeline = {} as any;
@@ -297,7 +358,7 @@ describe("runAgentSession cache — bookId switch", () => {
   });
   });
 
 
   it("恢复 transcript 中的 toolResult message", async () => {
   it("恢复 transcript 中的 toolResult message", async () => {
-    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const model = { provider: "anthropic", id: "fake", api: "anthropic-messages" } as any;
     const pipeline = {} as any;
     const pipeline = {} as any;
 
 
     await runAgentSession(
     await runAgentSession(
@@ -327,4 +388,227 @@ describe("runAgentSession cache — bookId switch", () => {
     ) as any;
     ) as any;
     expect(toolResult.sourceToolAssistantUuid).toBe(toolAssistant.uuid);
     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("返回 pi-agent-core final assistant error,供 API 层保留真实根因", async () => {
+    const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
+    const pipeline = {} as any;
+
+    const result = await runAgentSession(
+      { sessionId: "s1", bookId: "book-a", language: "zh", pipeline, projectRoot, model },
+      "model error",
+    );
+
+    expect(result.responseText).toBe("");
+    expect(result.errorMessage).toBe("400 status code (no body)");
+  });
 });
 });

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

@@ -411,6 +411,211 @@ describe("session transcript restore", () => {
     expect((adapted[0] as any).content).toEqual(messages[0].content);
     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("跨模型恢复时把原生工具回合降级为 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 () => {
   it("派生 BookSession 时跳过没有正文的 assistant tool-use message", async () => {
     await appendTranscriptEvent(projectRoot, {
     await appendTranscriptEvent(projectRoot, {
       type: "session_created",
       type: "session_created",

+ 136 - 36
packages/core/src/agent/agent-session.ts

@@ -2,7 +2,7 @@ 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 {
@@ -23,6 +23,7 @@ import {
   readTranscriptEvents,
   readTranscriptEvents,
 } from "../interaction/session-transcript.js";
 } from "../interaction/session-transcript.js";
 import {
 import {
+  TOOL_RESULT_BRIDGE_TEXT,
   adaptRestoredAgentMessagesForModel,
   adaptRestoredAgentMessagesForModel,
   restoreAgentMessagesFromTranscript,
   restoreAgentMessagesFromTranscript,
 } from "../interaction/session-transcript-restore.js";
 } from "../interaction/session-transcript-restore.js";
@@ -58,6 +59,8 @@ export interface AgentSessionResult {
   responseText: string;
   responseText: string;
   /** Full raw Agent conversation history. */
   /** Full raw Agent conversation history. */
   messages: AgentMessage[];
   messages: AgentMessage[];
+  /** Upstream model error surfaced by pi-agent-core, if the final assistant turn failed. */
+  errorMessage?: string;
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -72,7 +75,7 @@ export interface AgentSessionResult {
 interface CachedAgent {
 interface CachedAgent {
   agent: Agent;
   agent: Agent;
   bookId: string | null;
   bookId: string | null;
-  modelId: string | null;
+  modelIdentity: string;
   allowSystemFileRead: boolean;
   allowSystemFileRead: boolean;
   lastCommittedSeq: number;
   lastCommittedSeq: number;
   lastActive: number;
   lastActive: number;
@@ -126,13 +129,6 @@ function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
   return getModel(provider as any, modelId as any);
   return getModel(provider as any, modelId as any);
 }
 }
 
 
-function modelIdFromSpec(spec: AgentSessionConfig["model"]): string | null {
-  if (!spec || typeof spec !== "object") return null;
-  if ("id" in spec && typeof spec.id === "string") return spec.id;
-  if ("modelId" in spec && typeof spec.modelId === "string") return spec.modelId;
-  return null;
-}
-
 function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
 function envFlagEnabled(value: string | undefined, defaultValue: boolean): boolean {
   if (value === undefined) return defaultValue;
   if (value === undefined) return defaultValue;
   if (value === "1" || value.toLowerCase() === "true") return true;
   if (value === "1" || value.toLowerCase() === "true") return true;
@@ -140,6 +136,15 @@ function envFlagEnabled(value: string | undefined, defaultValue: boolean): boole
   return defaultValue;
   return defaultValue;
 }
 }
 
 
+function agentModelIdentity(model: Model<Api>): string {
+  return [
+    model.api,
+    model.provider,
+    model.baseUrl ?? "",
+    model.id,
+  ].join("::");
+}
+
 async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
 async function latestCommittedSeq(projectRoot: string, sessionId: string): Promise<number> {
   const events = await readTranscriptEvents(projectRoot, sessionId);
   const events = await readTranscriptEvents(projectRoot, sessionId);
   return events
   return events
@@ -222,6 +227,102 @@ function extractTextFromAssistant(msg: AssistantMessage): string {
     .join("");
     .join("");
 }
 }
 
 
+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.
  */
  */
@@ -319,23 +420,19 @@ 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 requestedModelId = modelIdFromSpec(config.model);
+  const model = resolveModel(config.model);
+  const requestedModelIdentity = agentModelIdentity(model);
   const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, true);
   const allowSystemFileRead = config.allowSystemFileRead ?? envFlagEnabled(process.env.INKOS_AGENT_ALLOW_SYSTEM_READ, true);
 
 
   // ----- Resolve or create Agent -----
   // ----- Resolve or create Agent -----
   let cached = agentCache.get(sessionId);
   let cached = agentCache.get(sessionId);
 
 
   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 modelChanged = !!(
-      cached.modelId &&
-      requestedModelId &&
-      cached.modelId !== requestedModelId
-    );
+    // 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 bookChanged = cached.bookId !== bookId;
     const bookChanged = cached.bookId !== bookId;
     const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
     const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
 
 
@@ -346,7 +443,6 @@ export async function runAgentSession(
   }
   }
 
 
   if (!cached) {
   if (!cached) {
-    const model = resolveModel(config.model);
     const restoredMessages = adaptRestoredAgentMessagesForModel(
     const restoredMessages = adaptRestoredAgentMessagesForModel(
       await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
       await restoreAgentMessagesFromTranscript(projectRoot, sessionId),
       model,
       model,
@@ -374,6 +470,7 @@ export async function runAgentSession(
         messages: initialAgentMessages,
         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;
@@ -384,7 +481,7 @@ export async function runAgentSession(
     cached = {
     cached = {
       agent,
       agent,
       bookId,
       bookId,
-      modelId: model.id ?? requestedModelId,
+      modelIdentity: requestedModelIdentity,
       allowSystemFileRead,
       allowSystemFileRead,
       lastCommittedSeq: await latestCommittedSeq(projectRoot, sessionId),
       lastCommittedSeq: await latestCommittedSeq(projectRoot, sessionId),
       lastActive: Date.now(),
       lastActive: Date.now(),
@@ -483,23 +580,26 @@ export async function runAgentSession(
 
 
   // ----- Extract result -----
   // ----- Extract result -----
   const allMessages = agent.state.messages;
   const allMessages = agent.state.messages;
-  const responseText = extractResponseText(allMessages);
-
-  return { responseText, messages: allMessages.slice() };
-}
-
-/**
- * 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];
+  let finalAssistant: AssistantMessage | undefined;
+  for (let i = allMessages.length - 1; i >= 0; i--) {
+    const msg = allMessages[i];
     if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
     if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
-      return extractTextFromAssistant(msg as AssistantMessage);
+      finalAssistant = msg as AssistantMessage;
+      break;
     }
     }
   }
   }
-  return "";
+  const responseText = finalAssistant ? extractTextFromAssistant(finalAssistant) : "";
+  const errorMessage = finalAssistant &&
+    (finalAssistant.stopReason === "error" || finalAssistant.stopReason === "aborted") &&
+    finalAssistant.errorMessage
+      ? finalAssistant.errorMessage
+      : undefined;
+
+  return {
+    responseText,
+    messages: allMessages.slice(),
+    ...(errorMessage ? { errorMessage } : {}),
+  };
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 140 - 16
packages/core/src/interaction/session-transcript-restore.ts

@@ -68,7 +68,7 @@ const emptyUsage = {
   cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
   cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
 };
 };
 
 
-const TOOL_RESULT_BRIDGE_TEXT = "I have processed the tool results.";
+export const TOOL_RESULT_BRIDGE_TEXT = "I have processed the tool results.";
 
 
 function toolResultBridgeMessage(timestamp: number): AgentMessage {
 function toolResultBridgeMessage(timestamp: number): AgentMessage {
   return {
   return {
@@ -151,25 +151,149 @@ export function adaptRestoredAgentMessagesForModel(
   messages: AgentMessage[],
   messages: AgentMessage[],
   target: TargetModelIdentity,
   target: TargetModelIdentity,
 ): AgentMessage[] {
 ): AgentMessage[] {
-  return messages
-    .map((message) => {
+  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 (
       if (
-        !isObject(message) ||
-        message.role !== "assistant" ||
-        !Array.isArray(message.content) ||
-        isSameAssistantModel(message, target)
+        isBridge &&
+        isObject(previous) &&
+        previous.role === "user" &&
+        typeof previous.content === "string" &&
+        previous.content.startsWith("[Tool results]")
       ) {
       ) {
-        return message;
+        continue;
       }
       }
 
 
-      const content = message.content.filter((block) => !isThinkingBlock(block));
-      if (content.length === message.content.length) return message;
-      return { ...message, content } as AgentMessage;
-    })
-    .filter((message) => {
-      if (!isObject(message) || message.role !== "assistant") return true;
-      return hasTextContent(message) || hasToolCallContent(message);
-    });
+      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);
+  }
+
+  return adapted.filter((message) => {
+    if (!isObject(message) || message.role !== "assistant") return true;
+    return hasTextContent(message) || hasToolCallContent(message);
+  });
 }
 }
 
 
 export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {
 export function committedMessageEvents(events: TranscriptEvent[]): MessageEvent[] {