import type { AgentMessage } from "@f5-sales-demo/pi-agent-core"; import type { AssistantMessage, ImageContent, Message } from "@f5-sales-demo/pi-ai"; import { Spacer, Text, TruncatedText } from "@f5-sales-demo/pi-tui"; import { settings } from "../../config/settings"; import { createMarkdownMediaOptions } from "../../media/markdown-resolver"; import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message"; import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message"; import { CustomMessageComponent } from "../../modes/components/custom-message"; import { createSystemGutter, createTextGutter, createToolGutter, GutterBlock, } from "../../modes/components/gutter-block"; import { MediaMessageComponent } from "../../modes/components/media-message"; import { PythonExecutionComponent } from "../../modes/components/python-execution"; import { ReadToolGroupComponent } from "../../modes/components/read-tool-group"; import { SkillMessageComponent } from "../../modes/components/skill-message"; import { ToolExecutionComponent } from "../../modes/components/tool-execution"; import { UserMessageComponent } from "../../modes/components/user-message"; import { theme } from "../../modes/theme/theme"; import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types"; import { ReadGroupOutcomeAggregator } from "../../modes/utils/read-group-outcome-aggregator"; import { type CustomMessage, SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages"; import type { SessionContext } from "../../session/session-manager"; import { formatBytes, formatDuration } from "../../tools/render-utils"; type TextBlock = { type: "text"; text: string }; type QueuedMessages = { steering: string[]; followUp: string[]; }; export class UiHelpers { constructor(private ctx: InteractiveModeContext) {} /** Extract text content from a user message */ getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const textBlocks = typeof message.content === "string" ? [{ type: "text", text: message.content }] : message.content.filter((content): content is TextBlock => content.type === "text"); return textBlocks.map(block => block.text).join(""); } /** * Show a status message in the chat. * * If multiple status messages are emitted back-to-back (without anything else being added to the chat), * we update the previous status line instead of appending new ones to avoid log spam. */ showStatus(message: string, options?: { dim?: boolean }): void { if (this.ctx.isBackgrounded) { return; } const children = this.ctx.chatContainer.children; const last = children.length > 0 ? children[children.length - 1] : undefined; const secondLast = children.length > 1 ? children[children.length - 2] : undefined; const useDim = options?.dim ?? true; const rendered = useDim ? theme.fg("dim", message) : message; if (last && secondLast && last === this.ctx.lastStatusText && secondLast === this.ctx.lastStatusSpacer) { this.ctx.lastStatusText.setText(rendered); this.ctx.ui.requestRender(); return; } const spacer = new Spacer(1); const text = new Text(rendered, 1, 0); this.ctx.chatContainer.addChild(spacer); this.ctx.chatContainer.addChild(text); this.ctx.lastStatusSpacer = spacer; this.ctx.lastStatusText = text; this.ctx.ui.requestRender(); } addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { switch (message.role) { case "bashExecution": { const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation, }); const gutter = createToolGutter(this.ctx.ui, component); gutter.setDone(component.outcome); this.ctx.chatContainer.addChild(gutter); break; } case "pythonExecution": { const component = new PythonExecutionComponent(message.code, this.ctx.ui, message.excludeFromContext); if (message.output) { component.appendOutput(message.output); } component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation, }); const gutter = createToolGutter(this.ctx.ui, component); gutter.setDone(component.outcome); this.ctx.chatContainer.addChild(gutter); break; } case "hookMessage": case "custom": { if (message.display) { if (message.customType === "async-result") { const details = ( message as CustomMessage<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number; }> ).details; const jobId = details?.jobId ?? "unknown"; const typeLabel = details?.type ? `[${details.type}]` : "[job]"; const duration = typeof details?.durationMs === "number" ? formatDuration(details.durationMs) : undefined; const line = [ theme.fg("success", `${theme.status.success} Background job completed`), theme.fg("dim", typeLabel), theme.fg("contentAccent", jobId), duration ? theme.fg("dim", `(${duration})`) : undefined, ] .filter(Boolean) .join(" "); this.ctx.chatContainer.addChild(createSystemGutter(this.ctx.ui, new Text(line, 1, 0))); break; } if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { const component = new SkillMessageComponent(message as CustomMessage); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(createTextGutter(this.ctx.ui, component)); break; } const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType); // Both HookMessage and CustomMessage have the same structure, cast for compatibility const component = new CustomMessageComponent(message as CustomMessage, renderer); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(createTextGutter(this.ctx.ui, component)); } break; } case "compactionSummary": { this.ctx.chatContainer.addChild(new Spacer(1)); const component = new CompactionSummaryMessageComponent(message); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(createSystemGutter(this.ctx.ui, component)); break; } case "branchSummary": { this.ctx.chatContainer.addChild(new Spacer(1)); const component = new BranchSummaryMessageComponent(message); component.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(createSystemGutter(this.ctx.ui, component)); break; } case "fileMention": { // Render compact file mention display for (const file of message.files) { let suffix: string; if (file.skippedReason === "tooLarge") { const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; suffix = `(skipped: ${size})`; } else { suffix = file.image ? "(image)" : file.lineCount === undefined ? "(unknown lines)" : `(${file.lineCount} lines)`; } const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( "chromeAccent", file.path, )} ${theme.fg("dim", suffix)}`; this.ctx.chatContainer.addChild(createTextGutter(this.ctx.ui, new Text(text, 0, 0))); } break; } case "user": case "developer": { const textContent = this.ctx.getUserMessageText(message); if (textContent) { const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); const userComponent = new UserMessageComponent(textContent, isSynthetic); this.ctx.chatContainer.addChild(userComponent); if (options?.populateHistory && message.role === "user" && !isSynthetic) { this.ctx.editor.addToHistory(textContent); } } break; } case "assistant": { const assistantComponent = new AssistantMessageComponent( message, this.ctx.hideThinkingBlock, createMarkdownMediaOptions(this.ctx.sessionManager, () => this.ctx.ui.requestRender()), ); this.ctx.chatContainer.addChild(createTextGutter(this.ctx.ui, assistantComponent)); break; } case "media": { const component = new MediaMessageComponent(message, this.ctx.sessionManager.getBlobStore(), this.ctx.ui, { autoplay: this.ctx.session.settings.get("media.autoplay"), reducedMotion: this.ctx.session.settings.get("media.reducedMotion"), fpsCap: this.ctx.session.settings.get("media.fpsCap"), }); this.ctx.chatContainer.addChild(createTextGutter(this.ctx.ui, component)); break; } case "toolResult": { // Tool results are rendered inline with tool calls, handled separately break; } default: { const _exhaustive: never = message; } } } /** * Render session context to chat. Used for initial load and rebuild after compaction. * @param sessionContext Session context to render * @param options.updateFooter Update footer state * @param options.populateHistory Add user messages to editor history */ renderSessionContext( sessionContext: SessionContext, options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.ctx.optimisticUserMessageSignature = undefined; this.ctx.pendingTools.clear(); if (options.updateFooter) { this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); } let readGroup: ReadToolGroupComponent | null = null; // Parallel to `readGroup`: the wrapping gutter for the current read // group. Held so the aggregator can finalize the correct gutter at // each group boundary. let readGroupGutter: ReturnType | null = null; // IDs of reads added to the current group but not yet matched to a // toolResult. Any still-pending read at group boundary counts as an // "error" outcome for the group, matching the live // `agent_end`-time error coloring of orphaned tools. let unmatchedReadsInGroup = new Set(); const readGroupAggregator = new ReadGroupOutcomeAggregator(); const finalizeReadGroup = (): void => { if (readGroupGutter) { // Any read in this group that never received a toolResult // is an orphan → record error so the group aggregates to // "error" instead of silently ending on the last success. for (const _id of unmatchedReadsInGroup) { readGroupAggregator.record(readGroupGutter, "error"); } readGroupAggregator.finalize(readGroupGutter); } readGroup = null; readGroupGutter = null; unmatchedReadsInGroup = new Set(); }; const readToolCallArgs = new Map>(); const readToolCallAssistantComponents = new Map(); const toolGutters = new Map>(); const deferredMessages: AgentMessage[] = []; for (const message of sessionContext.messages) { // Defer compaction summaries so they render at the bottom (visible after scroll) if (message.role === "compactionSummary") { deferredMessages.push(message); continue; } // Assistant messages need special handling for tool calls if (message.role === "assistant") { this.ctx.addMessageToChat(message); const lastChild = this.ctx.chatContainer.children[this.ctx.chatContainer.children.length - 1]; const unwrapped = lastChild instanceof GutterBlock ? lastChild.child : lastChild; const assistantComponent = unwrapped instanceof AssistantMessageComponent ? unwrapped : undefined; if (assistantComponent) { assistantComponent.setUsageInfo(message.usage); } // New assistant message — finalize the previous group so its // gutter resolves with the worst outcome seen so far. finalizeReadGroup(); const hasErrorStop = message.stopReason === "aborted" || message.stopReason === "error"; const errorMessage = hasErrorStop ? message.stopReason === "aborted" ? (() => { const retryAttempt = this.ctx.session.retryAttempt; return retryAttempt > 0 ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` : "Operation aborted"; })() : message.errorMessage || "Error" : null; // Render tool call components for (const content of message.content) { if (content.type !== "toolCall") { continue; } if (content.name === "read") { if (hasErrorStop && errorMessage) { if (!readGroup) { readGroup = new ReadToolGroupComponent(); readGroup.setExpanded(this.ctx.toolOutputExpanded); readGroupGutter = createToolGutter(this.ctx.ui, readGroup); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(readGroupGutter); } if (readGroupGutter) { readGroupAggregator.record(readGroupGutter, "error"); } // error-stop path injects an immediate error // result, so the read is NOT unmatched. unmatchedReadsInGroup.delete(content.id); readGroup.updateArgs(content.arguments, content.id); readGroup.updateResult( { content: [{ type: "text", text: errorMessage }], isError: true }, false, content.id, ); } else { const normalizedArgs = content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) ? (content.arguments as Record) : {}; readToolCallArgs.set(content.id, normalizedArgs); if (assistantComponent) { readToolCallAssistantComponents.set(content.id, assistantComponent); } // Track this read as part of the current group. // A matching toolResult will remove it; anything // left at group boundary is an unmatched orphan // and counts as an error for the aggregator. unmatchedReadsInGroup.add(content.id); } continue; } // Non-read tool call breaks the group. finalizeReadGroup(); if (content.name === "todo_write" && !settings.get("todo.verbose")) { continue; } const tool = this.ctx.session.getToolByName(content.name); const renderArgs = "partialJson" in content ? { ...content.arguments, __partialJson: content.partialJson } : content.arguments; const component = new ToolExecutionComponent( content.name, renderArgs, { showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), }, tool, this.ctx.ui, this.ctx.sessionManager.getCwd(), content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); const toolGutter = createToolGutter(this.ctx.ui, component); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(toolGutter); if (hasErrorStop && errorMessage) { component.updateResult( { content: [{ type: "text", text: errorMessage }], isError: true }, false, content.id, ); toolGutter.setDone("error"); } else { // Tool result hasn't arrived yet — keep gutter active until completion this.ctx.pendingTools.set(content.id, component); toolGutters.set(content.id, toolGutter); } } } else if (message.role === "toolResult") { if (message.toolName === "read") { const assistantComponent = readToolCallAssistantComponents.get(message.toolCallId); const images: ImageContent[] = message.content.filter( (content): content is ImageContent => content.type === "image", ); if (images.length > 0 && assistantComponent && settings.get("terminal.showImages")) { assistantComponent.setToolResultImages(message.toolCallId, images); const hasText = message.content.some(c => c.type === "text"); if (!hasText) { // Image-only reads are still successful reads — // record them into the current group aggregate // and remove from unmatched tracking so the // group does not wrongly aggregate to "error" // on a subsequent boundary. if (readGroupGutter) { readGroupAggregator.record(readGroupGutter, message.isError ? "error" : "success"); } unmatchedReadsInGroup.delete(message.toolCallId); readToolCallArgs.delete(message.toolCallId); readToolCallAssistantComponents.delete(message.toolCallId); continue; } } const readOutcome: "success" | "error" = message.isError ? "error" : "success"; let component = this.ctx.pendingTools.get(message.toolCallId); if (!component) { if (!readGroup) { readGroup = new ReadToolGroupComponent(); readGroup.setExpanded(this.ctx.toolOutputExpanded); readGroupGutter = createToolGutter(this.ctx.ui, readGroup); this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(readGroupGutter); } const args = readToolCallArgs.get(message.toolCallId); if (args) { readGroup.updateArgs(args, message.toolCallId); } component = readGroup; this.ctx.pendingTools.set(message.toolCallId, readGroup); } if (readGroupGutter) { readGroupAggregator.record(readGroupGutter, readOutcome); } // This read now has a matching result — it is no longer // an unmatched orphan for the group's aggregation. unmatchedReadsInGroup.delete(message.toolCallId); component.updateResult(message, false, message.toolCallId); this.ctx.pendingTools.delete(message.toolCallId); toolGutters.get(message.toolCallId)?.setDone(readOutcome); toolGutters.delete(message.toolCallId); readToolCallArgs.delete(message.toolCallId); readToolCallAssistantComponents.delete(message.toolCallId); continue; } // Match tool results to pending tool components. // // Persisted async-running results (details.async.state === // "running") describe jobs that were active when the // session was saved — the persisted outcome is neither // success nor failure, just "incomplete". Finalize the // gutter with the neutral (dim) done color so rebuilt // transcripts don't misreport them as successful. True // resumption of such a job across sessions would require // handing the gutter to the live EventController's // private #pendingGutters map — out of scope for this // rebuild. const component = this.ctx.pendingTools.get(message.toolCallId); if (component) { const asyncState = (message.details as { async?: { state?: string } } | undefined)?.async?.state; const isAsyncRunning = asyncState === "running"; component.updateResult(message, false, message.toolCallId); this.ctx.pendingTools.delete(message.toolCallId); const gutter = toolGutters.get(message.toolCallId); if (gutter) { if (isAsyncRunning) { // Neutral "completed state" color — not // success and not error. gutter.setDone(); } else { gutter.setDone(message.isError ? "error" : "success"); } } toolGutters.delete(message.toolCallId); } } else { // All other messages use standard rendering this.ctx.addMessageToChat(message, options); } } // Render deferred messages (compaction summaries) at the bottom so they're visible for (const message of deferredMessages) { this.ctx.addMessageToChat(message, options); } // Finalize any still-open read group at the tail of the transcript. // This also records "error" for any reads in the final group that // never received a toolResult, so incomplete groups aggregate to // error rather than silently closing on the last success. finalizeReadGroup(); // Tool gutters without a matching result mean the session was // persisted with an unfinished tool — an aborted/errored turn. // Inject an error body so the component renders as failed (it has // no prior streamed content during a rebuild), and mark the // gutter error. for (const [toolCallId, gutter] of toolGutters.entries()) { const component = this.ctx.pendingTools.get(toolCallId); component?.updateResult( { content: [{ type: "text", text: "Tool call did not complete" }], isError: true }, false, toolCallId, ); this.ctx.pendingTools.delete(toolCallId); gutter.setDone("error"); } toolGutters.clear(); this.ctx.ui.requestRender(); } renderInitialMessages(): void { // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI). // Clear existing rendered chat first to avoid duplicating the full session in the container. this.ctx.chatContainer.clear(); this.ctx.pendingMessagesContainer.clear(); this.ctx.pendingBashComponents = []; this.ctx.pendingPythonComponents = []; // Get aligned messages and entries from session context const context = this.ctx.sessionManager.buildSessionContext(); this.ctx.renderSessionContext(context, { updateFooter: true, populateHistory: true, }); // Show compaction info if session was compacted const allEntries = this.ctx.sessionManager.getEntries(); let compactionCount = 0; for (const entry of allEntries) { if (entry.type === "compaction") { compactionCount++; } } if (compactionCount > 0) { const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; this.ctx.showStatus(`Session compacted ${times}`); } } clearEditor(): void { if (this.ctx.isBackgrounded) { return; } this.ctx.editor.setText(""); this.ctx.pendingImages = []; this.ctx.ui.requestRender(); } showError(errorMessage: string): void { if (this.ctx.isBackgrounded) { process.stderr.write(`Error: ${errorMessage}\n`); return; } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); this.ctx.ui.requestRender(); } showWarning(warningMessage: string): void { if (this.ctx.isBackgrounded) { process.stderr.write(`Warning: ${warningMessage}\n`); return; } this.ctx.chatContainer.addChild(new Spacer(1)); this.ctx.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); this.ctx.ui.requestRender(); } updatePendingMessagesDisplay(): void { this.ctx.pendingMessagesContainer.clear(); const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages; const steeringMessages: Array<{ message: string; label: string }> = []; for (const message of queuedMessages.steering) { steeringMessages.push({ message, label: "Steer" }); } for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) { if (entry.mode === "steer") { steeringMessages.push({ message: entry.text, label: "Steer" }); } } const followUpMessages: Array<{ message: string; label: string }> = []; for (const message of queuedMessages.followUp) { followUpMessages.push({ message, label: "Follow-up" }); } for (const entry of this.ctx.compactionQueuedMessages as CompactionQueuedMessage[]) { if (entry.mode === "followUp") { followUpMessages.push({ message: entry.text, label: "Follow-up" }); } } const allMessages = [...steeringMessages, ...followUpMessages]; if (allMessages.length > 0) { this.ctx.pendingMessagesContainer.addChild(new Spacer(1)); for (const entry of allMessages) { const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); } const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up"; const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`); this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } } queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { this.ctx.compactionQueuedMessages.push({ text, mode } as CompactionQueuedMessage); this.ctx.editor.addToHistory(text); this.ctx.editor.setText(""); this.ctx.updatePendingMessagesDisplay(); this.ctx.showStatus("Queued message for after compaction"); } isKnownSlashCommand(text: string): boolean { if (!text.startsWith("/")) return false; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); if (!commandName) return false; if (this.ctx.session.extensionRunner?.getCommand(commandName)) { return true; } for (const command of this.ctx.session.customCommands) { if (command.command.name === commandName) { return true; } } return this.ctx.fileSlashCommands.has(commandName); } async flushCompactionQueue(options?: { willRetry?: boolean }): Promise { if (this.ctx.compactionQueuedMessages.length === 0) { return; } const queuedMessages = [...(this.ctx.compactionQueuedMessages as CompactionQueuedMessage[])]; this.ctx.compactionQueuedMessages = [] as CompactionQueuedMessage[]; this.ctx.updatePendingMessagesDisplay(); const restoreQueue = (error: unknown) => { this.ctx.session.clearQueue(); this.ctx.compactionQueuedMessages = queuedMessages; this.ctx.updatePendingMessagesDisplay(); this.ctx.showError( `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ error instanceof Error ? error.message : String(error) }`, ); }; try { if (options?.willRetry) { for (const message of queuedMessages) { if (this.ctx.isKnownSlashCommand(message.text)) { await this.ctx.session.prompt(message.text); } else if (message.mode === "followUp") { await this.ctx.session.followUp(message.text); } else { await this.ctx.session.steer(message.text); } } this.ctx.updatePendingMessagesDisplay(); return; } let firstPromptIndex = -1; for (let i = 0; i < queuedMessages.length; i++) { if (!this.ctx.isKnownSlashCommand(queuedMessages[i].text)) { firstPromptIndex = i; break; } } if (firstPromptIndex === -1) { for (const message of queuedMessages) { await this.ctx.session.prompt(message.text); } return; } const preCommands = queuedMessages.slice(0, firstPromptIndex); const firstPrompt = queuedMessages[firstPromptIndex]; const rest = queuedMessages.slice(firstPromptIndex + 1); for (const message of preCommands) { await this.ctx.session.prompt(message.text); } const promptPromise = this.ctx.session.prompt(firstPrompt.text).catch((error: unknown) => { restoreQueue(error); }); for (const message of rest) { if (this.ctx.isKnownSlashCommand(message.text)) { await this.ctx.session.prompt(message.text); } else if (message.mode === "followUp") { await this.ctx.session.followUp(message.text); } else { await this.ctx.session.steer(message.text); } } this.ctx.updatePendingMessagesDisplay(); void promptPromise; } catch (error) { restoreQueue(error); } } /** Move pending bash components from pending area to chat. * These commands have already completed (handleBashCommand/handlePythonCommand await execution) * so the gutter is immediately set to done. */ flushPendingBashComponents(): void { for (const component of this.ctx.pendingBashComponents) { this.ctx.pendingMessagesContainer.removeChild(component); const gutter = createToolGutter(this.ctx.ui, component); gutter.setDone(component.outcome); this.ctx.chatContainer.addChild(gutter); } this.ctx.pendingBashComponents = []; for (const component of this.ctx.pendingPythonComponents) { this.ctx.pendingMessagesContainer.removeChild(component); const gutter = createToolGutter(this.ctx.ui, component); gutter.setDone(component.outcome); this.ctx.chatContainer.addChild(gutter); } this.ctx.pendingPythonComponents = []; } findLastAssistantMessage(): AssistantMessage | undefined { for (let i = this.ctx.session.messages.length - 1; i >= 0; i--) { const message = this.ctx.session.messages[i]; if (message?.role === "assistant") { return message as AssistantMessage; } } return undefined; } extractAssistantText(message: AssistantMessage): string { let text = ""; for (const content of message.content) { if (content.type === "text") { text += content.text; } } return text.trim(); } }