import type { ActionEvent, OpenHandsEvent, ObservationEvent, } from "#/types/agent-server/core"; import type { TaskAction } from "#/types/agent-server/core/base/action"; import type { TaskObservation } from "#/types/agent-server/core/base/observation"; import { isACPToolCallEvent, isActionEvent, isAgentErrorEvent, isConversationStateUpdateEvent, isGoalConversationStateUpdateEvent, isHookExecutionEvent, isMessageEvent, isObservationEvent, isStreamingDeltaEvent, isSwitchLLMObservationEvent, } from "#/types/agent-server/type-guards"; import { handleEventForUI } from "#/utils/handle-event-for-ui"; import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; import { parseMessageFromEvent } from "#/components/conversation-events/chat/event-content-helpers/parse-message-from-event"; import { getActionContent } from "#/components/conversation-events/chat/event-content-helpers/get-action-content"; import { getObservationContent } from "#/components/conversation-events/chat/event-content-helpers/get-observation-content"; import { getACPToolCallContent, stripRedundantTitlePrefix, } from "#/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content"; import { groupEvents } from "#/components/conversation-events/chat/group-events"; import { getActionThoughtText, getReasoningContent, splitInlineThink, } from "#/components/conversation-events/chat/event-thought-helpers"; import i18n from "#/i18n"; import { I18nKey } from "#/i18n/declaration"; export type TranscriptExportFormat = "markdown" | "html"; export interface TranscriptExportOptions { includeToolDetails: boolean; includeTimestamps: boolean; title?: string | null; model?: string | null; } type TranscriptEntry = | { kind: "message"; author: "user" | "assistant"; content: string; timestamp: string; } | { kind: "tool"; summary: string; details: string; timestamp: string; } | { kind: "error"; content: string; timestamp: string; } | { kind: "note"; summary: string; content: string; timestamp: string; }; const SAFE_ACTION_DETAIL_KINDS = new Set([ "BrowserClickAction", "BrowserCloseTabAction", "BrowserGetContentAction", "BrowserGetStateAction", "BrowserGoBackAction", "BrowserListTabsAction", "BrowserNavigateAction", "BrowserScrollAction", "BrowserSwitchTabAction", "BrowserTypeAction", "ExecuteBashAction", "FileEditorAction", "GlobAction", "GrepAction", "InvokeSkillAction", "MCPToolAction", "StrReplaceEditorAction", "TaskAction", "TaskTrackerAction", "TerminalAction", "ThinkAction", ]); const SAFE_OBSERVATION_DETAIL_KINDS = new Set([ "BrowserObservation", "CanvasUIObservation", "ExecuteBashObservation", "FileEditorObservation", "GlobObservation", "GrepObservation", "InvokeSkillObservation", "MCPToolObservation", "StrReplaceEditorObservation", "SwitchLLMObservation", "TaskTrackerObservation", "TaskObservation", "TerminalObservation", ]); const cleanInlineText = (value: string): string => value .replace(/[\r\n]+/g, " ") .replace(/\s+/g, " ") .trim(); const truncate = (value: string, maxLength = 100): string => value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; const formatTimestamp = (timestamp: string): string => { const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? timestamp : date.toISOString(); }; const escapeHtml = (value: string): string => value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); /** * GitHub Markdown permits raw HTML, so dynamic conversation text needs the * same inert representation as the chat's sanitized Markdown renderer. */ const sanitizeMarkdownText = (value: string): string => value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replace( /(!?)\[([^\]]*)\]\(\s*((?:javascript|data|vbscript):[^)]*)\)/gi, "$1\\[$2\\]($3)", ) .replace( /^(\s{0,3})\[([^\]]+)\]:(\s* cleanInlineText(title || i18n.t(I18nKey.TRANSCRIPT_EXPORT$DEFAULT_TITLE)) || i18n.t(I18nKey.TRANSCRIPT_EXPORT$DEFAULT_TITLE); const translatePlain = ( key: I18nKey, values?: Record, ): string => cleanInlineText(i18n.t(key, values).replace(/<\/?(?:cmd|path)>/g, "")); const isServerFallbackSummary = (summary: string): boolean => /^[a-z][a-z0-9_]*\s*:\s*[[{]/i.test(summary); const humanizeEventKind = (kind: string): string => kind .replace(/(?:Action|Observation)$/, "") .replace(/([a-z])([A-Z])/g, "$1 $2") .trim(); const getActionSummary = (event: ActionEvent): string => { const summary = cleanInlineText(event.summary || ""); if (summary && !isServerFallbackSummary(summary)) return summary; return ( cleanInlineText(event.tool_name) || humanizeEventKind(event.action.kind) ); }; const getObservationSummary = ( event: ObservationEvent, correspondingAction?: ActionEvent, ): string => { if (correspondingAction) return getActionSummary(correspondingAction); return ( cleanInlineText(event.tool_name) || humanizeEventKind(event.observation.kind) ); }; const getTextContent = ( content: Array<{ type: string; text?: string }>, ): string => content .filter( (item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string", ) .map((item) => item.text) .join("\n"); const getTaskActionDetails = (action: TaskAction): string => [ `${i18n.t(I18nKey.TASK$SUBAGENT)}: ${action.subagent_type}`, `${i18n.t(I18nKey.TASK$QUERY)}:\n${action.prompt}`, ].join("\n\n"); const getTaskObservationDetails = ( observation: TaskObservation, correspondingAction?: ActionEvent, ): string => { const taskAction = correspondingAction?.action.kind === "TaskAction" ? correspondingAction.action : undefined; return [ `${i18n.t(I18nKey.TASK$SUBAGENT)}: ${observation.subagent}`, `${i18n.t(I18nKey.TASK$TASK_ID)}: ${observation.task_id}`, taskAction ? `${i18n.t(I18nKey.TASK$QUERY)}:\n${taskAction.prompt}` : "", `${i18n.t(I18nKey.TASK$RESULT)}:\n${getTextContent(observation.content)}`, ] .filter(Boolean) .join("\n\n"); }; const getSafeActionDetails = (event: ActionEvent): string => { if (event.action.kind === "TaskAction") { return getTaskActionDetails(event.action); } return SAFE_ACTION_DETAIL_KINDS.has(event.action.kind) ? getActionContent(event) : ""; }; const getSafeObservationDetails = ( event: ObservationEvent, correspondingAction?: ActionEvent, ): string => { if (event.observation.kind === "TaskObservation") { return getTaskObservationDetails(event.observation, correspondingAction); } return SAFE_OBSERVATION_DETAIL_KINDS.has(event.observation.kind) ? getObservationContent(event) : ""; }; const getHookDetails = (event: OpenHandsEvent): string => { if (!isHookExecutionEvent(event)) return ""; return [event.reason, event.error, event.stdout, event.stderr] .filter((value): value is string => Boolean(value?.trim())) .join("\n\n"); }; const getActionNarration = (event: ActionEvent): string => [getReasoningContent(event), getActionThoughtText(event)] .map((content) => content.trim()) .filter(Boolean) .join("\n\n"); const canRenderActionNarration = (event: ActionEvent): boolean => !["FinishAction", "SwitchLLMAction", "ThinkAction"].includes( event.action.kind, ); const buildTranscriptEntries = ( events: OpenHandsEvent[], includeToolDetails: boolean, ): TranscriptEntry[] => { const uiEvents = events.reduce((current, event) => { try { return handleEventForUI(event, current); } catch { return current; } }, []); const actionsById = new Map( events.filter(isActionEvent).map((event) => [event.id, event]), ); const entries: TranscriptEntry[] = []; const emittedNarrationActionIds = new Set(); const renderableEvents = uiEvents.filter( (event) => (isSwitchLLMObservationEvent(event) && !event.observation.is_error) || shouldRenderEvent(event), ); const renderedItems = groupEvents( renderableEvents, Number.MAX_SAFE_INTEGER, events, ); const addActionNarration = (action: ActionEvent) => { if ( emittedNarrationActionIds.has(action.id) || !canRenderActionNarration(action) ) { return; } emittedNarrationActionIds.add(action.id); const content = getActionNarration(action); if (content) { entries.push({ kind: "message", author: "assistant", content, timestamp: action.timestamp, }); } }; for (const item of renderedItems) { if (item.kind === "group") continue; if (item.kind === "thought") { addActionNarration(item.action); continue; } const { event } = item; try { const narrationAction = isActionEvent(event) ? event : isObservationEvent(event) ? actionsById.get(event.action_id) : undefined; if (narrationAction) addActionNarration(narrationAction); if (isSwitchLLMObservationEvent(event) && !event.observation.is_error) { entries.push({ kind: "note", summary: translatePlain(I18nKey.MODEL$SWITCHED_TO_PROFILE, { name: event.observation.profile_name, }), content: [ event.observation.active_model ? `${i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL)}: ${event.observation.active_model}` : "", event.observation.reason ? `${i18n.t(I18nKey.TRANSCRIPT_EXPORT$REASON)}: ${event.observation.reason}` : "", ] .filter(Boolean) .join("\n"), timestamp: event.timestamp ?? "", }); continue; } if (isMessageEvent(event)) { const parsed = parseMessageFromEvent(event).trim(); if (event.source === "agent") { const { reasoning, message } = splitInlineThink(parsed); [reasoning, message].filter(Boolean).forEach((content) => { entries.push({ kind: "message", author: "assistant", content, timestamp: event.timestamp ?? "", }); }); } else if (parsed) { entries.push({ kind: "message", author: "user", content: parsed, timestamp: event.timestamp ?? "", }); } continue; } if (isStreamingDeltaEvent(event)) { const { reasoning: inlineThink, message } = splitInlineThink( event.content ?? "", { streaming: true }, ); const reasoningContent = [ event.reasoning_content?.trim() || "", inlineThink, ] .filter(Boolean) .join("\n\n"); if (reasoningContent) { entries.push({ kind: "message", author: "assistant", content: reasoningContent, timestamp: event.timestamp ?? "", }); } if (message.trim()) { entries.push({ kind: "message", author: "assistant", content: message.trim(), timestamp: event.timestamp ?? "", }); } continue; } if (isAgentErrorEvent(event)) { entries.push({ kind: "error", content: event.error, timestamp: event.timestamp ?? "", }); continue; } if (isActionEvent(event)) { if (event.action.kind === "FinishAction") { const content = event.action.message.trim(); if (content) { entries.push({ kind: "message", author: "assistant", content, timestamp: event.timestamp ?? "", }); } } else { entries.push({ kind: "tool", summary: getActionSummary(event), details: includeToolDetails ? getSafeActionDetails(event) : "", timestamp: event.timestamp ?? "", }); } continue; } if (isObservationEvent(event)) { const correspondingAction = actionsById.get(event.action_id); entries.push({ kind: "tool", summary: getObservationSummary(event, correspondingAction), details: includeToolDetails ? getSafeObservationDetails(event, correspondingAction) : "", timestamp: event.timestamp ?? "", }); continue; } if (isACPToolCallEvent(event)) { entries.push({ kind: "tool", summary: stripRedundantTitlePrefix(event) || i18n.t(I18nKey.ACTION_MESSAGE$ACP_TOOL), details: includeToolDetails ? getACPToolCallContent(event) : "", timestamp: event.timestamp ?? "", }); continue; } if (isHookExecutionEvent(event)) { entries.push({ kind: "tool", summary: i18n.t(I18nKey.TRANSCRIPT_EXPORT$HOOK, { command: truncate(cleanInlineText(event.hook_command), 100), }), details: includeToolDetails ? getHookDetails(event) : "", timestamp: event.timestamp ?? "", }); continue; } if ( isConversationStateUpdateEvent(event) && isGoalConversationStateUpdateEvent(event) ) { entries.push({ kind: "note", summary: `${i18n.t(I18nKey.GOAL$PREFIX)} ${i18n.t( { running: I18nKey.GOAL$STATUS_RUNNING, complete: I18nKey.GOAL$STATUS_COMPLETE, capped: I18nKey.GOAL$STATUS_CAPPED, interrupted: I18nKey.GOAL$STATUS_INTERRUPTED, }[event.value.status], )}`, content: [event.value.objective, event.value.verdict?.missing || ""] .filter(Boolean) .join("\n\n"), timestamp: event.timestamp ?? "", }); } } catch { // Runtime event payloads may be older than the local TypeScript schema. // Skip malformed entries instead of aborting an otherwise valid export. } } return entries; }; const markdownTimestamp = ( entry: TranscriptEntry, options: TranscriptExportOptions, ): string => options.includeTimestamps ? `${escapeHtml(formatTimestamp(entry.timestamp))}\n\n` : ""; const markdownFence = (content: string): string => { const longestRun = Math.max( 0, ...Array.from(content.matchAll(/`+/g), (match) => match[0].length), ); const fence = "`".repeat(Math.max(3, longestRun + 1)); return `${fence}text\n${content}\n${fence}`; }; export const eventsToMarkdown = ( events: OpenHandsEvent[], options: TranscriptExportOptions, ): string => { const lines = [`# ${sanitizeMarkdownText(safeTitle(options.title))}`, ""]; if (options.model) { lines.push( `**${i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL)}:** ${sanitizeMarkdownText(cleanInlineText(options.model))}`, "", ); } for (const entry of buildTranscriptEntries( events, options.includeToolDetails, )) { const timestamp = markdownTimestamp(entry, options); if (entry.kind === "message") { const author = entry.author === "user" ? i18n.t(I18nKey.TRANSCRIPT_EXPORT$USER) : i18n.t(I18nKey.CHAT_INTERFACE$ASSISTANT); lines.push( `## ${author}`, "", timestamp + sanitizeMarkdownText(entry.content), "", ); } else if (entry.kind === "error") { const quoted = entry.content .split("\n") .map((line) => `> ${sanitizeMarkdownText(line)}`) .join("\n"); lines.push( `## ${i18n.t(I18nKey.COMMON$ERROR)}`, "", timestamp + quoted, "", ); } else if (entry.kind === "note") { lines.push( `> **${escapeHtml(entry.summary)}**`, "", timestamp + sanitizeMarkdownText(entry.content), "", ); } else if (options.includeToolDetails && entry.details) { lines.push( "
", `${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}: ${escapeHtml(entry.summary)}`, "", timestamp + markdownFence(entry.details), "", "
", "", ); } else { const timestampMarkup = timestamp ? `
${timestamp.trimEnd()}` : ""; lines.push( `

${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}: ${escapeHtml(entry.summary)}${timestampMarkup}

`, "", ); } } return `${lines.join("\n").trim()}\n`; }; const htmlTimestamp = ( entry: TranscriptEntry, options: TranscriptExportOptions, ): string => { if (!options.includeTimestamps) return ""; const timestamp = formatTimestamp(entry.timestamp); return ``; }; export const eventsToHtml = ( events: OpenHandsEvent[], options: TranscriptExportOptions, ): string => { const title = safeTitle(options.title); const body = buildTranscriptEntries(events, options.includeToolDetails) .map((entry) => { const timestamp = htmlTimestamp(entry, options); if (entry.kind === "message") { const author = entry.author === "user" ? i18n.t(I18nKey.TRANSCRIPT_EXPORT$USER) : i18n.t(I18nKey.CHAT_INTERFACE$ASSISTANT); return `

${escapeHtml(author)}

${timestamp}
${escapeHtml(entry.content)}
`; } if (entry.kind === "error") { return `

${escapeHtml(i18n.t(I18nKey.COMMON$ERROR))}

${timestamp}
${escapeHtml(entry.content)}
`; } if (entry.kind === "note") { return ``; } const details = options.includeToolDetails && entry.details ? `
${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}: ${escapeHtml(entry.summary)}${timestamp}
${escapeHtml(entry.details)}
` : `
${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$TOOL))}: ${escapeHtml(entry.summary)}${timestamp}
`; return details; }) .join("\n"); const model = options.model ? `

${escapeHtml(i18n.t(I18nKey.TRANSCRIPT_EXPORT$MODEL))}: ${escapeHtml(cleanInlineText(options.model))}

` : ""; return ` ${escapeHtml(title)}

${escapeHtml(title)}

${model} ${body}
`; };