Jelajahi Sumber

fix: restore tool executions in session ui

fanghanjun 6 bulan lalu
induk
melakukan
9af31f5e13

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

@@ -765,4 +765,134 @@ describe("session transcript restore", () => {
       ],
       ],
     });
     });
   });
   });
+
+  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: "角色目录已查看。",
+        toolExecutions: [{
+          id: "ls-1",
+          tool: "ls",
+          label: "列目录",
+          status: "completed",
+          args: { bookId: "book-a", subdir: "story/roles" },
+          result: "主要角色/\n次要角色/",
+          startedAt: 4,
+          completedAt: 5,
+        }],
+      },
+    ]);
+  });
 });
 });

+ 133 - 8
packages/core/src/interaction/session-transcript-restore.ts

@@ -349,7 +349,10 @@ function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
   return null;
   return null;
 }
 }
 
 
-function messageEventToInteractionMessage(event: MessageEvent): InteractionMessage | null {
+function messageEventToInteractionMessage(
+  event: MessageEvent,
+  restoredToolExecutions?: ToolExecution[],
+): 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;
   if (event.role === "toolResult") return null;
   if (event.role === "toolResult") return null;
@@ -362,14 +365,15 @@ function messageEventToInteractionMessage(event: MessageEvent): InteractionMessa
   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 = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
+    const toolExecutions = restoredToolExecutions?.length
+      ? restoredToolExecutions
+      : event.legacyDisplay?.toolExecutions as ToolExecution[] | undefined;
     if (!content) return null;
     if (!content) return null;
     return {
     return {
       role: "assistant",
       role: "assistant",
       content,
       content,
       ...(thinking ? { thinking } : {}),
       ...(thinking ? { thinking } : {}),
-      ...(event.legacyDisplay?.toolExecutions
-        ? { toolExecutions: event.legacyDisplay.toolExecutions as ToolExecution[] }
-        : {}),
+      ...(toolExecutions?.length ? { toolExecutions } : {}),
       timestamp: event.timestamp,
       timestamp: event.timestamp,
     };
     };
   }
   }
@@ -382,6 +386,130 @@ function messageEventToInteractionMessage(event: MessageEvent): InteractionMessa
   return 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[] = [];
+
+  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(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(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 (event.role === "assistant" && isObject(raw)) {
+      rememberToolCalls(event, raw);
+      const message = messageEventToInteractionMessage(
+        event,
+        pendingToolExecutions.length > 0 ? pendingToolExecutions : undefined,
+      );
+      if (message) {
+        messages.push(message);
+        pendingToolExecutions = [];
+      }
+      continue;
+    }
+
+    if (event.role === "toolResult") {
+      const execution = toolExecutionFromResult(event);
+      if (execution) pendingToolExecutions.push(execution);
+      continue;
+    }
+
+    const message = messageEventToInteractionMessage(event);
+    if (message) messages.push(message);
+  }
+
+  return messages.sort((a, b) => a.timestamp - b.timestamp);
+}
+
 export async function deriveBookSessionFromTranscript(
 export async function deriveBookSessionFromTranscript(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
@@ -406,10 +534,7 @@ export async function deriveBookSessionFromTranscript(
     updatedAt = event.updatedAt;
     updatedAt = event.updatedAt;
   }
   }
 
 
-  const messages = committedMessageEvents(events)
-    .map(messageEventToInteractionMessage)
-    .filter((message): message is InteractionMessage => message !== null)
-    .sort((a, b) => a.timestamp - b.timestamp);
+  const messages = messageEventsToInteractionMessages(committedMessageEvents(events));
 
 
   if (title === null) {
   if (title === null) {
     title = firstUserMessageTitle(messages);
     title = firstUserMessageTitle(messages);

+ 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;
 }
 }