Просмотр исходного кода

feat: add assistant metadata to session export (#6611)

Dillon Mulroy 1 месяц назад
Родитель
Сommit
05eee679a3

+ 43 - 90
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -68,6 +68,7 @@ import { usePromptRef } from "../../context/prompt"
 import { Filesystem } from "@/util/filesystem"
 import { PermissionPrompt } from "./permission"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
+import { formatTranscript } from "../../util/transcript"
 
 addDefaultParsers(parsers.parsers)
 
@@ -134,6 +135,7 @@ export function Session() {
   const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
   const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
   const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
+  const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
   const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
   const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
   const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
@@ -712,47 +714,17 @@ export function Session() {
       category: "Session",
       onSelect: async (dialog) => {
         try {
-          // Format session transcript as markdown
           const sessionData = session()
           const sessionMessages = messages()
-
-          let transcript = `# ${sessionData.title}\n\n`
-          transcript += `**Session ID:** ${sessionData.id}\n`
-          transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
-          transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
-          transcript += `---\n\n`
-
-          for (const msg of sessionMessages) {
-            const parts = sync.data.part[msg.id] ?? []
-            const role = msg.role === "user" ? "User" : "Assistant"
-            transcript += `## ${role}\n\n`
-
-            for (const part of parts) {
-              if (part.type === "text" && !part.synthetic) {
-                transcript += `${part.text}\n\n`
-              } else if (part.type === "reasoning") {
-                if (showThinking()) {
-                  transcript += `_Thinking:_\n\n${part.text}\n\n`
-                }
-              } else if (part.type === "tool") {
-                transcript += `\`\`\`\nTool: ${part.tool}\n`
-                if (showDetails() && part.state.input) {
-                  transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
-                }
-                if (showDetails() && part.state.status === "completed" && part.state.output) {
-                  transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
-                }
-                if (showDetails() && part.state.status === "error" && part.state.error) {
-                  transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
-                }
-                transcript += `\n\`\`\`\n\n`
-              }
-            }
-
-            transcript += `---\n\n`
-          }
-
-          // Copy to clipboard
+          const transcript = formatTranscript(
+            sessionData,
+            sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
+            {
+              thinking: showThinking(),
+              toolDetails: showDetails(),
+              assistantMetadata: showAssistantMetadata(),
+            },
+          )
           await Clipboard.copy(transcript)
           toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
         } catch (error) {
@@ -762,75 +734,56 @@ export function Session() {
       },
     },
     {
-      title: "Export session transcript to file",
+      title: "Export session transcript",
       value: "session.export",
       keybind: "session_export",
       category: "Session",
       onSelect: async (dialog) => {
         try {
-          // Format session transcript as markdown
           const sessionData = session()
           const sessionMessages = messages()
 
           const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
 
-          const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
+          const options = await DialogExportOptions.show(
+            dialog,
+            defaultFilename,
+            showThinking(),
+            showDetails(),
+            showAssistantMetadata(),
+            false,
+          )
 
           if (options === null) return
 
-          const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options
-
-          let transcript = `# ${sessionData.title}\n\n`
-          transcript += `**Session ID:** ${sessionData.id}\n`
-          transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
-          transcript += `**Updated:** ${new Date(sessionData.time.updated).toLocaleString()}\n\n`
-          transcript += `---\n\n`
-
-          for (const msg of sessionMessages) {
-            const parts = sync.data.part[msg.id] ?? []
-            const role = msg.role === "user" ? "User" : "Assistant"
-            transcript += `## ${role}\n\n`
-
-            for (const part of parts) {
-              if (part.type === "text" && !part.synthetic) {
-                transcript += `${part.text}\n\n`
-              } else if (part.type === "reasoning") {
-                if (includeThinking) {
-                  transcript += `_Thinking:_\n\n${part.text}\n\n`
-                }
-              } else if (part.type === "tool") {
-                transcript += `\`\`\`\nTool: ${part.tool}\n`
-                if (includeToolDetails && part.state.input) {
-                  transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
-                }
-                if (includeToolDetails && part.state.status === "completed" && part.state.output) {
-                  transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
-                }
-                if (includeToolDetails && part.state.status === "error" && part.state.error) {
-                  transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
-                }
-                transcript += `\n\`\`\`\n\n`
-              }
-            }
+          const transcript = formatTranscript(
+            sessionData,
+            sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
+            {
+              thinking: options.thinking,
+              toolDetails: options.toolDetails,
+              assistantMetadata: options.assistantMetadata,
+            },
+          )
 
-            transcript += `---\n\n`
-          }
+          if (options.openWithoutSaving) {
+            // Just open in editor without saving
+            await Editor.open({ value: transcript, renderer })
+          } else {
+            const exportDir = process.cwd()
+            const filename = options.filename.trim()
+            const filepath = path.join(exportDir, filename)
 
-          // Save to file in current working directory
-          const exportDir = process.cwd()
-          const filename = customFilename.trim()
-          const filepath = path.join(exportDir, filename)
+            await Bun.write(filepath, transcript)
 
-          await Bun.write(filepath, transcript)
+            // Open with EDITOR if available
+            const result = await Editor.open({ value: transcript, renderer })
+            if (result !== undefined) {
+              await Bun.write(filepath, result)
+            }
 
-          // Open with EDITOR if available
-          const result = await Editor.open({ value: transcript, renderer })
-          if (result !== undefined) {
-            // User edited the file, save the changes
-            await Bun.write(filepath, result)
+            toast.show({ message: `Session exported to ${filename}`, variant: "success" })
           }
-
-          toast.show({ message: `Session exported to ${filename}`, variant: "success" })
         } catch (error) {
           toast.show({ message: "Failed to export session", variant: "error" })
         }

+ 60 - 4
packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx

@@ -9,7 +9,15 @@ export type DialogExportOptionsProps = {
   defaultFilename: string
   defaultThinking: boolean
   defaultToolDetails: boolean
-  onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
+  defaultAssistantMetadata: boolean
+  defaultOpenWithoutSaving: boolean
+  onConfirm?: (options: {
+    filename: string
+    thinking: boolean
+    toolDetails: boolean
+    assistantMetadata: boolean
+    openWithoutSaving: boolean
+  }) => void
   onCancel?: () => void
 }
 
@@ -20,7 +28,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
   const [store, setStore] = createStore({
     thinking: props.defaultThinking,
     toolDetails: props.defaultToolDetails,
-    active: "filename" as "filename" | "thinking" | "toolDetails",
+    assistantMetadata: props.defaultAssistantMetadata,
+    openWithoutSaving: props.defaultOpenWithoutSaving,
+    active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
   })
 
   useKeyboard((evt) => {
@@ -29,10 +39,18 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
         filename: textarea.plainText,
         thinking: store.thinking,
         toolDetails: store.toolDetails,
+        assistantMetadata: store.assistantMetadata,
+        openWithoutSaving: store.openWithoutSaving,
       })
     }
     if (evt.name === "tab") {
-      const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
+      const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
+        "filename",
+        "thinking",
+        "toolDetails",
+        "assistantMetadata",
+        "openWithoutSaving",
+      ]
       const currentIndex = order.indexOf(store.active)
       const nextIndex = (currentIndex + 1) % order.length
       setStore("active", order[nextIndex])
@@ -41,6 +59,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
     if (evt.name === "space") {
       if (store.active === "thinking") setStore("thinking", !store.thinking)
       if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
+      if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
+      if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
       evt.preventDefault()
     }
   })
@@ -71,6 +91,8 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
               filename: textarea.plainText,
               thinking: store.thinking,
               toolDetails: store.toolDetails,
+              assistantMetadata: store.assistantMetadata,
+              openWithoutSaving: store.openWithoutSaving,
             })
           }}
           height={3}
@@ -108,6 +130,30 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
           </text>
           <text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
         </box>
+        <box
+          flexDirection="row"
+          gap={2}
+          paddingLeft={1}
+          backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
+          onMouseUp={() => setStore("active", "assistantMetadata")}
+        >
+          <text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
+            {store.assistantMetadata ? "[x]" : "[ ]"}
+          </text>
+          <text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
+        </box>
+        <box
+          flexDirection="row"
+          gap={2}
+          paddingLeft={1}
+          backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
+          onMouseUp={() => setStore("active", "openWithoutSaving")}
+        >
+          <text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
+            {store.openWithoutSaving ? "[x]" : "[ ]"}
+          </text>
+          <text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
+        </box>
       </box>
       <Show when={store.active !== "filename"}>
         <text fg={theme.textMuted} paddingBottom={1}>
@@ -130,14 +176,24 @@ DialogExportOptions.show = (
   defaultFilename: string,
   defaultThinking: boolean,
   defaultToolDetails: boolean,
+  defaultAssistantMetadata: boolean,
+  defaultOpenWithoutSaving: boolean,
 ) => {
-  return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
+  return new Promise<{
+    filename: string
+    thinking: boolean
+    toolDetails: boolean
+    assistantMetadata: boolean
+    openWithoutSaving: boolean
+  } | null>((resolve) => {
     dialog.replace(
       () => (
         <DialogExportOptions
           defaultFilename={defaultFilename}
           defaultThinking={defaultThinking}
           defaultToolDetails={defaultToolDetails}
+          defaultAssistantMetadata={defaultAssistantMetadata}
+          defaultOpenWithoutSaving={defaultOpenWithoutSaving}
           onConfirm={(options) => resolve(options)}
           onCancel={() => resolve(null)}
         />

+ 98 - 0
packages/opencode/src/cli/cmd/tui/util/transcript.ts

@@ -0,0 +1,98 @@
+import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2"
+import { Locale } from "@/util/locale"
+
+export type TranscriptOptions = {
+  thinking: boolean
+  toolDetails: boolean
+  assistantMetadata: boolean
+}
+
+export type SessionInfo = {
+  id: string
+  title: string
+  time: {
+    created: number
+    updated: number
+  }
+}
+
+export type MessageWithParts = {
+  info: UserMessage | AssistantMessage
+  parts: Part[]
+}
+
+export function formatTranscript(
+  session: SessionInfo,
+  messages: MessageWithParts[],
+  options: TranscriptOptions,
+): string {
+  let transcript = `# ${session.title}\n\n`
+  transcript += `**Session ID:** ${session.id}\n`
+  transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
+  transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
+  transcript += `---\n\n`
+
+  for (const msg of messages) {
+    transcript += formatMessage(msg.info, msg.parts, options)
+    transcript += `---\n\n`
+  }
+
+  return transcript
+}
+
+export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string {
+  let result = ""
+
+  if (msg.role === "user") {
+    result += `## User\n\n`
+  } else {
+    result += formatAssistantHeader(msg, options.assistantMetadata)
+  }
+
+  for (const part of parts) {
+    result += formatPart(part, options)
+  }
+
+  return result
+}
+
+export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string {
+  if (!includeMetadata) {
+    return `## Assistant\n\n`
+  }
+
+  const duration =
+    msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
+
+  return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n`
+}
+
+export function formatPart(part: Part, options: TranscriptOptions): string {
+  if (part.type === "text" && !part.synthetic) {
+    return `${part.text}\n\n`
+  }
+
+  if (part.type === "reasoning") {
+    if (options.thinking) {
+      return `_Thinking:_\n\n${part.text}\n\n`
+    }
+    return ""
+  }
+
+  if (part.type === "tool") {
+    let result = `\`\`\`\nTool: ${part.tool}\n`
+    if (options.toolDetails && part.state.input) {
+      result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+    }
+    if (options.toolDetails && part.state.status === "completed" && part.state.output) {
+      result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+    }
+    if (options.toolDetails && part.state.status === "error" && part.state.error) {
+      result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+    }
+    result += `\n\`\`\`\n\n`
+    return result
+  }
+
+  return ""
+}

+ 297 - 0
packages/opencode/test/cli/tui/transcript.test.ts

@@ -0,0 +1,297 @@
+import { describe, expect, test } from "bun:test"
+import {
+  formatAssistantHeader,
+  formatMessage,
+  formatPart,
+  formatTranscript,
+} from "../../../src/cli/cmd/tui/util/transcript"
+import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2"
+
+describe("transcript", () => {
+  describe("formatAssistantHeader", () => {
+    const baseMsg: AssistantMessage = {
+      id: "msg_123",
+      sessionID: "ses_123",
+      role: "assistant",
+      agent: "build",
+      modelID: "claude-sonnet-4-20250514",
+      providerID: "anthropic",
+      mode: "",
+      parentID: "msg_parent",
+      path: { cwd: "/test", root: "/test" },
+      cost: 0.001,
+      tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
+      time: { created: 1000000, completed: 1005400 },
+    }
+
+    test("includes metadata when enabled", () => {
+      const result = formatAssistantHeader(baseMsg, true)
+      expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n")
+    })
+
+    test("excludes metadata when disabled", () => {
+      const result = formatAssistantHeader(baseMsg, false)
+      expect(result).toBe("## Assistant\n\n")
+    })
+
+    test("handles missing completed time", () => {
+      const msg = { ...baseMsg, time: { created: 1000000 } }
+      const result = formatAssistantHeader(msg as AssistantMessage, true)
+      expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n")
+    })
+
+    test("titlecases agent name", () => {
+      const msg = { ...baseMsg, agent: "plan" }
+      const result = formatAssistantHeader(msg, true)
+      expect(result).toContain("Plan")
+    })
+  })
+
+  describe("formatPart", () => {
+    const options = { thinking: true, toolDetails: true, assistantMetadata: true }
+
+    test("formats text part", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "text",
+        text: "Hello world",
+      }
+      const result = formatPart(part, options)
+      expect(result).toBe("Hello world\n\n")
+    })
+
+    test("skips synthetic text parts", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "text",
+        text: "Synthetic content",
+        synthetic: true,
+      }
+      const result = formatPart(part, options)
+      expect(result).toBe("")
+    })
+
+    test("formats reasoning when thinking enabled", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "reasoning",
+        text: "Let me think...",
+        time: { start: 1000 },
+      }
+      const result = formatPart(part, options)
+      expect(result).toBe("_Thinking:_\n\nLet me think...\n\n")
+    })
+
+    test("skips reasoning when thinking disabled", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "reasoning",
+        text: "Let me think...",
+        time: { start: 1000 },
+      }
+      const result = formatPart(part, { ...options, thinking: false })
+      expect(result).toBe("")
+    })
+
+    test("formats tool part with details", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "tool",
+        callID: "call_1",
+        tool: "bash",
+        state: {
+          status: "completed",
+          input: { command: "ls" },
+          output: "file1.txt\nfile2.txt",
+          title: "List files",
+          metadata: {},
+          time: { start: 1000, end: 1100 },
+        },
+      }
+      const result = formatPart(part, options)
+      expect(result).toContain("Tool: bash")
+      expect(result).toContain("**Input:**")
+      expect(result).toContain('"command": "ls"')
+      expect(result).toContain("**Output:**")
+      expect(result).toContain("file1.txt")
+    })
+
+    test("formats tool part without details when disabled", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "tool",
+        callID: "call_1",
+        tool: "bash",
+        state: {
+          status: "completed",
+          input: { command: "ls" },
+          output: "file1.txt",
+          title: "List files",
+          metadata: {},
+          time: { start: 1000, end: 1100 },
+        },
+      }
+      const result = formatPart(part, { ...options, toolDetails: false })
+      expect(result).toContain("Tool: bash")
+      expect(result).not.toContain("**Input:**")
+      expect(result).not.toContain("**Output:**")
+    })
+
+    test("formats tool error", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "tool",
+        callID: "call_1",
+        tool: "bash",
+        state: {
+          status: "error",
+          input: { command: "invalid" },
+          error: "Command failed",
+          time: { start: 1000, end: 1100 },
+        },
+      }
+      const result = formatPart(part, options)
+      expect(result).toContain("**Error:**")
+      expect(result).toContain("Command failed")
+    })
+  })
+
+  describe("formatMessage", () => {
+    const options = { thinking: true, toolDetails: true, assistantMetadata: true }
+
+    test("formats user message", () => {
+      const msg: UserMessage = {
+        id: "msg_123",
+        sessionID: "ses_123",
+        role: "user",
+        agent: "build",
+        model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+        time: { created: 1000000 },
+      }
+      const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }]
+      const result = formatMessage(msg, parts, options)
+      expect(result).toContain("## User")
+      expect(result).toContain("Hello")
+    })
+
+    test("formats assistant message with metadata", () => {
+      const msg: AssistantMessage = {
+        id: "msg_123",
+        sessionID: "ses_123",
+        role: "assistant",
+        agent: "build",
+        modelID: "claude-sonnet-4-20250514",
+        providerID: "anthropic",
+        mode: "",
+        parentID: "msg_parent",
+        path: { cwd: "/test", root: "/test" },
+        cost: 0.001,
+        tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
+        time: { created: 1000000, completed: 1005400 },
+      }
+      const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
+      const result = formatMessage(msg, parts, options)
+      expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)")
+      expect(result).toContain("Hi there")
+    })
+  })
+
+  describe("formatTranscript", () => {
+    test("formats complete transcript", () => {
+      const session = {
+        id: "ses_abc123",
+        title: "Test Session",
+        time: { created: 1000000000000, updated: 1000000001000 },
+      }
+      const messages = [
+        {
+          info: {
+            id: "msg_1",
+            sessionID: "ses_abc123",
+            role: "user" as const,
+            agent: "build",
+            model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
+            time: { created: 1000000000000 },
+          },
+          parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }],
+        },
+        {
+          info: {
+            id: "msg_2",
+            sessionID: "ses_abc123",
+            role: "assistant" as const,
+            agent: "build",
+            modelID: "claude-sonnet-4-20250514",
+            providerID: "anthropic",
+            mode: "",
+            parentID: "msg_1",
+            path: { cwd: "/test", root: "/test" },
+            cost: 0.001,
+            tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
+            time: { created: 1000000000100, completed: 1000000000600 },
+          },
+          parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }],
+        },
+      ]
+      const options = { thinking: false, toolDetails: false, assistantMetadata: true }
+
+      const result = formatTranscript(session, messages, options)
+
+      expect(result).toContain("# Test Session")
+      expect(result).toContain("**Session ID:** ses_abc123")
+      expect(result).toContain("## User")
+      expect(result).toContain("Hello")
+      expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)")
+      expect(result).toContain("Hi!")
+      expect(result).toContain("---")
+    })
+
+    test("formats transcript without assistant metadata", () => {
+      const session = {
+        id: "ses_abc123",
+        title: "Test Session",
+        time: { created: 1000000000000, updated: 1000000001000 },
+      }
+      const messages = [
+        {
+          info: {
+            id: "msg_1",
+            sessionID: "ses_abc123",
+            role: "assistant" as const,
+            agent: "build",
+            modelID: "claude-sonnet-4-20250514",
+            providerID: "anthropic",
+            mode: "",
+            parentID: "msg_0",
+            path: { cwd: "/test", root: "/test" },
+            cost: 0.001,
+            tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
+            time: { created: 1000000000100, completed: 1000000000600 },
+          },
+          parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
+        },
+      ]
+      const options = { thinking: false, toolDetails: false, assistantMetadata: false }
+
+      const result = formatTranscript(session, messages, options)
+
+      expect(result).toContain("## Assistant\n\n")
+      expect(result).not.toContain("Build")
+      expect(result).not.toContain("claude-sonnet-4-20250514")
+    })
+  })
+})