Quellcode durchsuchen

feat: stop legacy session message writes

fanghanjun vor 6 Monaten
Ursprung
Commit
13e03d824d

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

@@ -3,11 +3,13 @@ import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import {
+  appendManualSessionMessages,
   appendTranscriptEvent,
   nextTranscriptSeq,
   readTranscriptEvents,
   transcriptPath,
 } from "../interaction/session-transcript.js";
+import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
 import type {
   MessageEvent,
   RequestCommittedEvent,
@@ -129,4 +131,37 @@ describe("session transcript codec", () => {
 
     await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
   });
+
+  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" }] },
+    ]);
+  });
 });

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

@@ -142,6 +142,23 @@ export {
   createAndPersistBookSession,
   SessionAlreadyMigratedError,
 } from "./interaction/book-session-store.js";
+export {
+  appendManualSessionMessages,
+  appendTranscriptEvent,
+  readTranscriptEvents,
+  nextTranscriptSeq,
+  transcriptPath,
+  legacyBookSessionPath,
+} from "./interaction/session-transcript.js";
+export type {
+  TranscriptEvent,
+  MessageEvent,
+  RequestCommittedEvent,
+  RequestFailedEvent,
+  RequestStartedEvent,
+  SessionCreatedEvent,
+  SessionMetadataUpdatedEvent,
+} from "./interaction/session-transcript-schema.js";
 export { routeInteractionRequest } from "./interaction/request-router.js";
 export {
   routeNaturalLanguageIntent,

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

@@ -1,6 +1,9 @@
+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>>();
@@ -64,3 +67,99 @@ export async function appendTranscriptEvent(
   appendQueues.set(key, next.catch(() => undefined));
   await next;
 }
+
+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();
+  let seq = await nextTranscriptSeq(projectRoot, sessionId);
+  await appendTranscriptEvent(projectRoot, {
+    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);
+    await appendTranscriptEvent(projectRoot, {
+      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;
+  }
+
+  await appendTranscriptEvent(projectRoot, {
+    type: "request_committed",
+    version: 1,
+    sessionId,
+    requestId,
+    seq,
+    timestamp: Date.now(),
+  });
+}

+ 56 - 1
packages/studio/src/api/server.test.ts

@@ -27,6 +27,7 @@ const createAndPersistBookSessionMock = vi.fn();
 const loadBookSessionMock = vi.fn();
 const persistBookSessionMock = vi.fn();
 const appendBookSessionMessageMock = vi.fn();
+const appendManualSessionMessagesMock = vi.fn();
 const renameBookSessionMock = vi.fn();
 const deleteBookSessionMock = vi.fn();
 const migrateBookSessionMock = vi.fn();
@@ -210,6 +211,7 @@ vi.mock("@actalk/inkos-core", () => {
     loadBookSession: loadBookSessionMock,
     persistBookSession: persistBookSessionMock,
     appendBookSessionMessage: appendBookSessionMessageMock,
+    appendManualSessionMessages: appendManualSessionMessagesMock,
     isNewLayoutBook: vi.fn(async () => false),
     renameBookSession: renameBookSessionMock,
     deleteBookSession: deleteBookSessionMock,
@@ -380,6 +382,7 @@ describe("createStudioServer daemon lifecycle", () => {
     loadBookSessionMock.mockReset();
     persistBookSessionMock.mockReset();
     appendBookSessionMessageMock.mockReset();
+    appendManualSessionMessagesMock.mockReset();
     renameBookSessionMock.mockReset();
     deleteBookSessionMock.mockReset();
     migrateBookSessionMock.mockReset();
@@ -410,6 +413,7 @@ describe("createStudioServer daemon lifecycle", () => {
     appendBookSessionMessageMock.mockImplementation(
       (session: unknown, _msg: unknown) => session,
     );
+    appendManualSessionMessagesMock.mockResolvedValue(undefined);
     renameBookSessionMock.mockResolvedValue(null);
     deleteBookSessionMock.mockResolvedValue(undefined);
     migrateBookSessionMock.mockImplementation(async (_root: string, _sessionId: string, bookId: string) => ({
@@ -1759,10 +1763,61 @@ describe("createStudioServer daemon lifecycle", () => {
         projectRoot: root,
       }),
       "continue",
-      expect.any(Array),
     );
   });
 
+  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 () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,

+ 31 - 43
packages/studio/src/api/server.ts

@@ -15,8 +15,7 @@ import {
   resolveSessionActiveBook,
   listBookSessions,
   loadBookSession,
-  persistBookSession,
-  appendBookSessionMessage,
+  appendManualSessionMessages,
   createAndPersistBookSession,
   renameBookSession,
   deleteBookSession,
@@ -1672,11 +1671,18 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
       let bookSession = loadedBookSession;
       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
       let resolvedModel: ResolvedModel["model"] | undefined;
@@ -1889,7 +1895,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           },
         },
         instruction,
-        initialMessages,
       );
 
       let broadcastedCreatedBookId: string | null = null;
@@ -1921,33 +1926,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         return createdBookId;
       };
 
-      // 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 });
-        }
-      }
-      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,
-        });
-      }
       if (!result.responseText) {
         try {
           const fallbackClient = createLLMClient({
@@ -1969,12 +1947,24 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
             { maxTokens: 256 },
           );
           if (fallback.content?.trim()) {
-            bookSession = appendBookSessionMessage(bookSession, {
+            await appendManualSessionMessages(root, bookSession.sessionId, [{
               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({
               response: fallback.content,
@@ -2007,7 +1997,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         } catch (probeError) {
           const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
           if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
-            await persistBookSession(root, bookSession);
             await finalizeCreatedBook();
           }
           return c.json({
@@ -2018,7 +2007,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
         const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
         if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
-          await persistBookSession(root, bookSession);
           await finalizeCreatedBook();
         }
         return c.json({
@@ -2026,7 +2014,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           response: emptyMessage,
         }, 502);
       }
-      await persistBookSession(root, bookSession);
+      await refreshBookSessionFromTranscript();
       await finalizeCreatedBook();
 
       broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });