mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
feat: add assistant metadata to session export (#6611)
This commit is contained in:
@@ -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
|
||||
const transcript = formatTranscript(
|
||||
sessionData,
|
||||
sessionMessages.map((msg) => ({ info: msg, parts: sync.data.part[msg.id] ?? [] })),
|
||||
{
|
||||
thinking: options.thinking,
|
||||
toolDetails: options.toolDetails,
|
||||
assistantMetadata: options.assistantMetadata,
|
||||
},
|
||||
)
|
||||
|
||||
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`
|
||||
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)
|
||||
|
||||
for (const msg of sessionMessages) {
|
||||
const parts = sync.data.part[msg.id] ?? []
|
||||
const role = msg.role === "user" ? "User" : "Assistant"
|
||||
transcript += `## ${role}\n\n`
|
||||
await Bun.write(filepath, transcript)
|
||||
|
||||
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`
|
||||
}
|
||||
// Open with EDITOR if available
|
||||
const result = await Editor.open({ value: transcript, renderer })
|
||||
if (result !== undefined) {
|
||||
await Bun.write(filepath, result)
|
||||
}
|
||||
|
||||
transcript += `---\n\n`
|
||||
toast.show({ message: `Session exported to ${filename}`, variant: "success" })
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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" })
|
||||
} catch (error) {
|
||||
toast.show({ message: "Failed to export session", variant: "error" })
|
||||
}
|
||||
|
||||
@@ -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
packages/opencode/src/cli/cmd/tui/util/transcript.ts
Normal file
98
packages/opencode/src/cli/cmd/tui/util/transcript.ts
Normal file
@@ -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
packages/opencode/test/cli/tui/transcript.test.ts
Normal file
297
packages/opencode/test/cli/tui/transcript.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user