import type { ChatTurn, ThreadMessage, ToolActivity } from "./types"; /** * Flattens stored turns into the flat message list the transcript renders. * * Storage keeps a question and its answer together as a turn, because that is * what gets replayed to the model. The transcript needs a flat sequence, newest * message first. This is the one place that translation happens. */ export interface LiveState { isStreaming: boolean; /** Partial answer for the turn currently in flight. */ response: string; /** * Reasoning to show for the turn in flight. * * Only the part since the last tool call, not the whole trace: the live * transcript clears it each time a tool starts, so a research turn does not * stack every round's thinking on screen. The stored turn keeps the whole * trace separately. */ thinking: string; /** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */ status?: string; /** The tool being waited on right now, which supersedes the plain status when present. */ activity?: ToolActivity; } /** * One line describing a tool that is running. * * Says "the same call again" rather than showing a bare count, because a number * on its own reads as progress. This is the visible half of the loop guard in * `agent.ts`, and the only warning the user gets before a turn is cut short. */ export function describeActivity(activity: ToolActivity): string { if (activity.count <= 1) return activity.label; return `${activity.label} ยท the same call again (${activity.count})`; } export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] { const messages: ThreadMessage[] = []; // Strictly newest message first. The search bar sits at the top of the window, // so the most recent thing, an answer once one has been written, should be // the first line under it. A consequence is that within an exchange the answer // comes before its question, which is the intended reading order here. for (let index = turns.length - 1; index >= 0; index--) { const turn = turns[index]; // Only the newest turn can be mid-flight. const isLive = index === turns.length - 1 && live.isStreaming; const text = isLive ? live.response : turn.response; messages.push({ id: `${turn.id}:assistant`, role: "assistant", text, createdAt: turn.createdAt, isStreaming: isLive, thinking: isLive ? live.thinking : turn.thinking, toolCalls: turn.toolCalls, error: isLive ? undefined : turn.error, // Only while there is nothing to read yet. Once tokens land, the // placeholder it feeds is gone anyway. status: isLive && text.length === 0 ? live.status : undefined, // The tool, on the other hand, is worth naming even after the answer has // started arriving: a tool that begins mid-answer would otherwise be // invisible, which was the original complaint about hidden tool calls. activity: isLive ? live.activity : undefined, // Only a question carries attachments, so the answer never has one. attachments: [], }); messages.push({ id: `${turn.id}:user`, role: "user", text: turn.prompt, createdAt: turn.createdAt, isStreaming: false, thinking: "", toolCalls: [], attachments: turn.attachments, }); } return messages; }