Przeglądaj źródła

fix: harden agent transcript recovery

fanghanjun 6 miesięcy temu
rodzic
commit
97e50ef133

+ 28 - 1
packages/core/src/__tests__/agent-session.test.ts

@@ -101,11 +101,16 @@ vi.mock("@mariozechner/pi-ai", async () => {
             ], timestamp)
             ], timestamp)
           : assistant([{ type: "text", text: "ok" }], timestamp);
           : assistant([{ type: "text", text: "ok" }], timestamp);
 
 
-    stream.push({
+    const done = () => stream.push({
       type: "done",
       type: "done",
       reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
       reason: message.stopReason === "toolUse" ? "toolUse" : "stop",
       message,
       message,
     });
     });
+    if (prompt.startsWith("slow ")) {
+      setTimeout(done, prompt.includes("first") ? 20 : 0);
+    } else {
+      done();
+    }
     return stream;
     return stream;
   });
   });
 
 
@@ -662,4 +667,26 @@ describe("runAgentSession cache — bookId switch", () => {
     expect(agentInstances).toHaveLength(instancesAfterError + 1);
     expect(agentInstances).toHaveLength(instancesAfterError + 1);
     expect(JSON.stringify(streamCalls.at(-1)?.context.messages)).not.toContain("model error");
     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));
+  });
 });
 });

+ 31 - 1
packages/core/src/__tests__/book-session-store.test.ts

@@ -14,7 +14,12 @@ import {
   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 { legacyBookSessionPath, readTranscriptEvents, sessionsDir } from "../interaction/session-transcript.js";
+import {
+  appendManualSessionMessages,
+  legacyBookSessionPath,
+  readTranscriptEvents,
+  sessionsDir,
+} from "../interaction/session-transcript.js";
 import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 
 
 describe("book-session-store", () => {
 describe("book-session-store", () => {
@@ -150,6 +155,17 @@ describe("book-session-store", () => {
         .toThrow();
         .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 () => {
     it("renameBookSession 追加 metadata event 并从 JSONL 派生新标题", async () => {
       await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
       await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
 
 
@@ -209,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);

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

@@ -939,6 +939,7 @@ describe("session transcript restore", () => {
       {
       {
         role: "assistant",
         role: "assistant",
         content: "角色目录已查看。",
         content: "角色目录已查看。",
+        thinking: "先列目录",
         toolExecutions: [{
         toolExecutions: [{
           id: "ls-1",
           id: "ls-1",
           tool: "ls",
           tool: "ls",
@@ -952,4 +953,72 @@ describe("session transcript restore", () => {
       },
       },
     ]);
     ]);
   });
   });
+
+  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(["先问", "后答"]);
+  });
 });
 });

+ 81 - 21
packages/core/src/agent/agent-session.ts

@@ -18,8 +18,8 @@ import {
 } from "./agent-tools.js";
 } from "./agent-tools.js";
 import { createBookContextTransform } from "./context-transform.js";
 import { createBookContextTransform } from "./context-transform.js";
 import {
 import {
+  appendTranscriptEvents,
   appendTranscriptEvent,
   appendTranscriptEvent,
-  nextTranscriptSeq,
   readTranscriptEvents,
   readTranscriptEvents,
 } from "../interaction/session-transcript.js";
 } from "../interaction/session-transcript.js";
 import {
 import {
@@ -67,21 +67,20 @@ export interface AgentSessionResult {
 // 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;
+  projectRoot: string;
   bookId: string | null;
   bookId: string | null;
+  language: string;
   modelIdentity: string;
   modelIdentity: string;
+  apiKey: string | undefined;
   allowSystemFileRead: boolean;
   allowSystemFileRead: boolean;
   lastCommittedSeq: number;
   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;
@@ -145,6 +144,35 @@ function agentModelIdentity(model: Model<Api>): string {
   ].join("::");
   ].join("::");
 }
 }
 
 
+function sessionQueueKey(projectRoot: string, sessionId: string): string {
+  return `${projectRoot}\0${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> {
 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
@@ -198,22 +226,29 @@ async function ensureSessionCreatedEvent(
   sessionId: string,
   sessionId: string,
   bookId: string | null,
   bookId: string | null,
 ): Promise<number> {
 ): Promise<number> {
-  const events = await readTranscriptEvents(projectRoot, sessionId);
-  if (events.length > 0) return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+  const created = await appendTranscriptEvents(projectRoot, sessionId, ({ events, nextSeq }) => {
+    if (events.length > 0) return [];
 
 
-  const now = Date.now();
-  await appendTranscriptEvent(projectRoot, {
-    type: "session_created",
-    version: 1,
-    sessionId,
-    seq: 1,
-    timestamp: now,
-    bookId,
-    title: null,
-    createdAt: now,
-    updatedAt: now,
+    const now = Date.now();
+    return [{
+      type: "session_created",
+      version: 1,
+      sessionId,
+      seq: nextSeq,
+      timestamp: now,
+      bookId,
+      title: null,
+      createdAt: now,
+      updatedAt: now,
+    }];
   });
   });
-  return 2;
+
+  if (created.length > 0) {
+    return created.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+  }
+
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
 }
 }
 
 
 /**
 /**
@@ -430,6 +465,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
@@ -453,11 +498,22 @@ export async function runAgentSession(
     // bookId via closures in systemPrompt / tools / transformContext), so a
     // bookId via closures in systemPrompt / tools / transformContext), so a
     // mismatch means the cached Agent would keep using stale context.
     // mismatch means the cached Agent would keep using stale context.
     const modelChanged = cached.modelIdentity !== requestedModelIdentity;
     const modelChanged = cached.modelIdentity !== requestedModelIdentity;
+    const projectRootChanged = cached.projectRoot !== projectRoot;
     const bookChanged = cached.bookId !== bookId;
     const bookChanged = cached.bookId !== bookId;
+    const languageChanged = cached.language !== language;
+    const apiKeyChanged = cached.apiKey !== config.apiKey;
     const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
     const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
     const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
     const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
 
 
-    if (modelChanged || bookChanged || readPermissionChanged || transcriptChanged) {
+    if (
+      modelChanged ||
+      projectRootChanged ||
+      bookChanged ||
+      languageChanged ||
+      apiKeyChanged ||
+      readPermissionChanged ||
+      transcriptChanged
+    ) {
       agentCache.delete(sessionId);
       agentCache.delete(sessionId);
       cached = undefined;
       cached = undefined;
     }
     }
@@ -501,8 +557,11 @@ export async function runAgentSession(
 
 
     cached = {
     cached = {
       agent,
       agent,
+      projectRoot,
       bookId,
       bookId,
+      language,
       modelIdentity: requestedModelIdentity,
       modelIdentity: requestedModelIdentity,
+      apiKey: config.apiKey,
       allowSystemFileRead,
       allowSystemFileRead,
       lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
       lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
       lastActive: Date.now(),
       lastActive: Date.now(),
@@ -613,6 +672,7 @@ export async function runAgentSession(
       timestamp: Date.now(),
       timestamp: Date.now(),
       error: error instanceof Error ? error.message : String(error),
       error: error instanceof Error ? error.message : String(error),
     });
     });
+    agentCache.delete(sessionId);
     throw error;
     throw error;
   } finally {
   } finally {
     unsubscribe();
     unsubscribe();

+ 14 - 11
packages/core/src/interaction/book-session-store.ts

@@ -57,17 +57,20 @@ async function appendSessionCreatedEvent(
   projectRoot: string,
   projectRoot: string,
   session: BookSession,
   session: BookSession,
 ): Promise<void> {
 ): Promise<void> {
-  await appendTranscriptEvents(projectRoot, session.sessionId, ({ nextSeq }) => [{
-    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,
-  }]);
+  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(
 async function appendSessionMetadataUpdatedEvent(

+ 28 - 3
packages/core/src/interaction/session-transcript-restore.ts

@@ -352,6 +352,13 @@ function thinkingFromContent(content: unknown): string | undefined {
   return value || undefined;
   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 {
 function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
   for (const message of messages) {
   for (const message of messages) {
     if (message.role !== "user") continue;
     if (message.role !== "user") continue;
@@ -365,6 +372,7 @@ function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
 function messageEventToInteractionMessage(
 function messageEventToInteractionMessage(
   event: MessageEvent,
   event: MessageEvent,
   restoredToolExecutions?: ToolExecution[],
   restoredToolExecutions?: ToolExecution[],
+  restoredThinking?: ReadonlyArray<string>,
 ): InteractionMessage | null {
 ): InteractionMessage | null {
   const raw = event.message as Record<string, unknown>;
   const raw = event.message as Record<string, unknown>;
   if (!isObject(raw)) return null;
   if (!isObject(raw)) return null;
@@ -377,7 +385,11 @@ function messageEventToInteractionMessage(
 
 
   if (event.role === "assistant") {
   if (event.role === "assistant") {
     const content = textFromContent(raw.content);
     const content = textFromContent(raw.content);
-    const thinking = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
+    const thinking = joinThinking([
+      ...(restoredThinking ?? []),
+      thinkingFromContent(raw.content),
+      event.legacyDisplay?.thinking,
+    ]);
     const toolExecutions = restoredToolExecutions?.length
     const toolExecutions = restoredToolExecutions?.length
       ? restoredToolExecutions
       ? restoredToolExecutions
       : event.legacyDisplay?.toolExecutions as ToolExecution[] | undefined;
       : event.legacyDisplay?.toolExecutions as ToolExecution[] | undefined;
@@ -424,6 +436,7 @@ function messageEventsToInteractionMessages(events: MessageEvent[]): Interaction
   const messages: InteractionMessage[] = [];
   const messages: InteractionMessage[] = [];
   const toolCalls = new Map<string, RestoredToolCall>();
   const toolCalls = new Map<string, RestoredToolCall>();
   let pendingToolExecutions: ToolExecution[] = [];
   let pendingToolExecutions: ToolExecution[] = [];
+  let pendingThinking: string[] = [];
 
 
   const objectArgs = (value: unknown): Record<string, unknown> | undefined => {
   const objectArgs = (value: unknown): Record<string, unknown> | undefined => {
     if (isObject(value)) return value;
     if (isObject(value)) return value;
@@ -499,13 +512,18 @@ function messageEventsToInteractionMessages(events: MessageEvent[]): Interaction
 
 
     if (event.role === "assistant" && isObject(raw)) {
     if (event.role === "assistant" && isObject(raw)) {
       rememberToolCalls(event, raw);
       rememberToolCalls(event, raw);
+      const currentThinking = thinkingFromContent(raw.content);
       const message = messageEventToInteractionMessage(
       const message = messageEventToInteractionMessage(
         event,
         event,
         pendingToolExecutions.length > 0 ? pendingToolExecutions : undefined,
         pendingToolExecutions.length > 0 ? pendingToolExecutions : undefined,
+        pendingThinking.length > 0 ? pendingThinking : undefined,
       );
       );
       if (message) {
       if (message) {
         messages.push(message);
         messages.push(message);
         pendingToolExecutions = [];
         pendingToolExecutions = [];
+        pendingThinking = [];
+      } else if (hasToolCallContent(raw) && currentThinking) {
+        pendingThinking.push(currentThinking);
       }
       }
       continue;
       continue;
     }
     }
@@ -520,7 +538,7 @@ function messageEventsToInteractionMessages(events: MessageEvent[]): Interaction
     if (message) messages.push(message);
     if (message) messages.push(message);
   }
   }
 
 
-  return messages.sort((a, b) => a.timestamp - b.timestamp);
+  return messages;
 }
 }
 
 
 export async function deriveBookSessionFromTranscript(
 export async function deriveBookSessionFromTranscript(
@@ -536,15 +554,22 @@ export async function deriveBookSessionFromTranscript(
   const createdAt = created?.type === "session_created"
   const createdAt = created?.type === "session_created"
     ? created.createdAt
     ? created.createdAt
     : events[0]?.timestamp ?? Date.now();
     : 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"
   let updatedAt = created?.type === "session_created"
     ? created.updatedAt
     ? created.updatedAt
     : events[events.length - 1]?.timestamp ?? createdAt;
     : events[events.length - 1]?.timestamp ?? createdAt;
+  updatedAt = Math.max(updatedAt, latestActivityTimestamp);
 
 
   for (const event of events) {
   for (const event of events) {
     if (event.type !== "session_metadata_updated") continue;
     if (event.type !== "session_metadata_updated") continue;
     if ("bookId" in event && event.bookId !== undefined) bookId = event.bookId;
     if ("bookId" in event && event.bookId !== undefined) bookId = event.bookId;
     if ("title" in event && event.title !== undefined) title = event.title;
     if ("title" in event && event.title !== undefined) title = event.title;
-    updatedAt = event.updatedAt;
+    updatedAt = Math.max(updatedAt, event.updatedAt);
   }
   }
 
 
   const messages = messageEventsToInteractionMessages(committedMessageEvents(events));
   const messages = messageEventsToInteractionMessages(committedMessageEvents(events));