| import { |
| deriveTurnRecords, |
| isInFlightToolStatus, |
| isActiveShellRunStatus, |
| mergeShellRunStateWithDiagnostics, |
| projectToolActivityArgs, |
| STEP_LIMIT_NOTICE_TEXT, |
| toolResultActivityStatus, |
| unfinishedToolActivityStatus, |
| } from "@maka/core"; |
| import type { |
| AttachmentRef, |
| ToolActivityStatus, |
| InlineReference, |
| QuoteRef, |
| ShellRunToolResult, |
| ShellRunUpdate, |
| StoredMessage, |
| ToolActivityKind, |
| ToolResultContent, |
| TurnRecord, |
| TurnStatus, |
| UserMessage, |
| } from "@maka/core"; |
| import type { LiveTurnProjection } from "./live-turn-projection.js"; |
| |
| export { |
| isCancelledToolResultContent, |
| isInFlightToolStatus, |
| toolResultActivityStatus, |
| } from "@maka/core"; |
| |
| export interface ChatItem { |
| id: string; |
| role: "user" | "assistant" | "system"; |
| text: string; |
| /** Wall-clock timestamp of the source StoredMessage; surfaced for hover meta. */ |
| ts?: number; |
| /** User-message attachments projected from StoredMessage; absent on assistant/system rows. */ |
| attachments?: AttachmentRef[]; |
| /** Inline quoted excerpts projected from StoredMessage; user rows only. */ |
| quotes?: QuoteRef[]; |
| /** Frozen inline token metadata projected from StoredMessage; user rows only. */ |
| inlineReferences?: InlineReference[]; |
| /** Present when the Host authored this message instead of the user. */ |
| hostOrigin?: NonNullable<UserMessage["origin"]>; |
| } |
| |
| /** |
| * One chunk from PR-REAL-4 `tool_output_delta`. The renderer keeps these |
| * per-tool, sorted by `seq` (per-toolCallId monotonic), so out-of-order |
| * arrivals are repaired and duplicates dropped. `redacted: true` signals |
| * the runtime suppressed a secret in this chunk; the UI renders a small |
| * "[已脱敏]" hint instead of pretending the chunk arrived clean. |
| */ |
| export interface ToolOutputChunk { |
| seq: number; |
| stream: "stdout" | "stderr"; |
| text: string; |
| redacted: boolean; |
| createdAt: number; |
| } |
| |
| export interface ToolActivityItem { |
| toolUseId: string; |
| toolName: string; |
| activityKind?: ToolActivityKind; |
| displayName?: string; |
| intent?: string; |
| origin?: 'provider' | 'code_mode'; |
| modelVisibility?: 'visible' | 'hidden'; |
| parentToolCallId?: string; |
| parentOperationId?: string; |
| /** |
| * Assistant step this tool belongs to (equals the step's AssistantMessage |
| * id). Populated from the persisted `tool_call.stepId`, or from the live |
| * `ToolStartEvent.stepId` for in-flight tools. The turn timeline uses it to |
| * place a step's tools after that step's thinking/text; absent means a |
| * legacy call with no step association. |
| */ |
| stepId?: string; |
| status: ToolActivityStatus; |
| args: unknown; |
| result?: ToolResultContent; |
| durationMs?: number; |
| /** |
| * Live streamed output buffer (PR-UI-12). Append-only from the |
| * renderer's perspective — runtime side already enforces the |
| * 256-char redaction tail and per-toolCallId seq monotonicity, so |
| * the UI only needs to: |
| * - dedupe by `seq` (drop chunks whose seq already exists) |
| * - keep the list sorted by `seq` (insert-sort on out-of-order) |
| * - render in two visual streams (stdout / stderr) but preserve |
| * the global seq order so interleaving reads correctly. |
| * |
| * PR-UI-12 review fixup #2 (@kenji A3 msg 365ff8b9): the renderer |
| * also runs each incoming chunk through `redactSecrets` and a |
| * size cap before appending — see `applyToolOutputChunk` in |
| * `tool-output-stream.ts`. Defense in depth against runtime |
| * tail-redactor misses. |
| */ |
| outputChunks?: ToolOutputChunk[]; |
| /** |
| * `true` when `applyToolOutputChunk` dropped/truncated content |
| * (per-chunk size cap, per-tool count cap, or per-tool total-char |
| * cap fired). UI surfaces this as a "已截断" pill so users know |
| * the visible stream is not the full underlying output. |
| */ |
| outputTruncated?: boolean; |
| /** Ownership state for a running ShellRun copied into a branched session. */ |
| shellRunSource?: "owned" | "unavailable"; |
| } |
| |
| // system_note kinds that we surface inline to the user. Everything else |
| // (session_resume, connection_locked, mode_change-as-internal-audit, …) |
| // stays in the JSONL audit trail but is hidden from the chat surface so |
| // the conversation reads like a conversation, not a debug log. |
| const VISIBLE_SYSTEM_NOTES = new Set<string>([ |
| "context_compacted", |
| "context_compaction_failed_open", |
| "step_limit", |
| ]); |
| |
| const SYSTEM_NOTE_LABELS: Record<string, string> = { |
| context_compacted: |
| "Context compacted to keep this session within the model window.", |
| context_compaction_failed_open: |
| "Context summary failed; the session continued without a new summary.", |
| step_limit: STEP_LIMIT_NOTICE_TEXT, |
| mode_change: "Permission mode changed", |
| turn_aborted: "Turn aborted", |
| }; |
| |
| export function materializeChat( |
| messages: readonly StoredMessage[], |
| ): ChatItem[] { |
| const items: ChatItem[] = []; |
| for (const message of messages) { |
| if (message.type === "user") { |
| items.push({ |
| id: message.id, |
| role: "user", |
| text: message.displayText ?? message.text, |
| ts: message.ts, |
| ...(message.attachments && message.attachments.length > 0 |
| ? { attachments: message.attachments } |
| : {}), |
| ...(message.quotes && message.quotes.length > 0 |
| ? { quotes: message.quotes } |
| : {}), |
| ...(message.inlineReferences !== undefined |
| ? { inlineReferences: message.inlineReferences } |
| : {}), |
| ...(message.origin ? { hostOrigin: message.origin } : {}), |
| }); |
| } |
| if (message.type === "assistant") |
| items.push({ |
| id: message.id, |
| role: "assistant", |
| text: message.text, |
| ts: message.ts, |
| }); |
| if ( |
| message.type === "system_note" && |
| VISIBLE_SYSTEM_NOTES.has(message.kind) |
| ) { |
| items.push({ |
| id: message.id, |
| role: "system", |
| text: SYSTEM_NOTE_LABELS[message.kind] ?? message.kind, |
| ts: message.ts, |
| }); |
| } |
| } |
| return items; |
| } |
| |
| export function materializeTools( |
| messages: readonly StoredMessage[], |
| ): ToolActivityItem[] { |
| const results = new Map( |
| messages |
| .filter((message) => message.type === "tool_result") |
| .map((message) => [message.toolUseId, message]), |
| ); |
| const turnStatusById = new Map( |
| deriveTurnRecords(messages).map((turn) => [turn.turnId, turn.status]), |
| ); |
| return messages |
| .filter((message) => message.type === "tool_call") |
| .map((call) => { |
| const result = results.get(call.id); |
| return { |
| toolUseId: call.id, |
| toolName: call.toolName, |
| activityKind: call.activityKind, |
| displayName: call.displayName, |
| intent: call.intent, |
| ...(call.origin !== undefined ? { origin: call.origin } : {}), |
| ...(call.modelVisibility !== undefined ? { modelVisibility: call.modelVisibility } : {}), |
| ...(call.parentToolCallId !== undefined ? { parentToolCallId: call.parentToolCallId } : {}), |
| ...(call.parentOperationId !== undefined ? { parentOperationId: call.parentOperationId } : {}), |
| ...(call.stepId !== undefined ? { stepId: call.stepId } : {}), |
| status: result |
| ? materializeToolResultStatus(result) |
| : unfinishedToolActivityStatus(turnStatusById.get(call.turnId)), |
| args: projectToolActivityArgs(call.toolName, call.args), |
| result: result?.content, |
| durationMs: result?.durationMs, |
| }; |
| }); |
| } |
| |
| function materializeToolResultStatus( |
| result: Extract<StoredMessage, { type: "tool_result" }>, |
| ): ToolActivityItem["status"] { |
| return toolResultActivityStatus(result.isError, result.content); |
| } |
| |
| /** |
| * Merge live tool state on top of the persisted tool. Live owns transient |
| * state: its events arrive ahead of the persisted transcript refresh, so it |
| * carries the most current status and output chunks. The durable transcript |
| * fills call arguments and settled results when the live path has no payload. |
| * Runtime Host live events deliberately omit both; letting those empty |
| * projections win made an expanded tool row blank until the whole Turn |
| * settled. |
| * |
| * The exception is a turn that has already ended. Live only stays current |
| * while events keep arriving, and the subscription does not replay — a missed |
| * `abort`/`error`/`complete` leaves the projection frozen mid-run, and |
| * `reconcileTerminalLiveTurn` hands a tool off only once it is `interrupted` |
| * or has a result, so a frozen `running` never clears. The turn's own settled |
| * status is the counter-evidence: once it has ended, no tool inside it is |
| * still running, and the persisted status is the authority. |
| * |
| * Live `outputChunks` always come from live — persisted JSONL doesn't |
| * store them (PR-REAL-4 contract: chunks are transient UI). |
| */ |
| function mergeLiveOverPersisted( |
| persisted: ToolActivityItem, |
| live: ToolActivityItem, |
| turnSettled: boolean, |
| ): ToolActivityItem { |
| const merged: ToolActivityItem = { ...persisted, ...live }; |
| if (live.args === undefined) { |
| merged.args = persisted.args; |
| } |
| const liveResultIsEmpty = |
| live.result === undefined || |
| (live.result.kind === "text" && live.result.text.length === 0); |
| if (persisted.result !== undefined && liveResultIsEmpty) { |
| // Runtime Host represents its deliberately omitted result payload as an |
| // empty text result at the SessionEvent compatibility seam. A transcript |
| // refresh can also win the race with the terminal live event, leaving no |
| // live result at all. In both cases the committed result supplies detail |
| // without taking a newer, meaningful live result away. |
| merged.result = persisted.result; |
| } |
| // A settled turn always yields a settled persisted status — materializeTools |
| // only reads a tool as in-flight while the turn record says `running` — so |
| // this needs no guard on the persisted side. |
| if (turnSettled && isInFlightToolStatus(live.status)) { |
| merged.status = persisted.status; |
| } |
| if (live.toolName === "Tool") { |
| merged.toolName = persisted.toolName; |
| merged.activityKind = persisted.activityKind; |
| merged.displayName = persisted.displayName; |
| merged.intent = persisted.intent; |
| merged.args = persisted.args; |
| } |
| if ( |
| merged.toolName === "Bash" && |
| persisted.result?.kind === "shell_run" && |
| live.result?.kind === "shell_run" |
| ) { |
| const shellRun = mergeShellRunStateWithDiagnostics( |
| persisted.result, |
| live.result, |
| "ui.live-over-persisted", |
| ).result; |
| merged.result = shellRun; |
| } |
| if (live.outputChunks && live.outputChunks.length > 0) { |
| merged.outputChunks = live.outputChunks; |
| } |
| return merged; |
| } |
| |
| /** |
| * One entry on a turn's render timeline — the interleaved thinking / answer / |
| * tool sequence in the order the model actually produced it. This is the |
| * rendering source of truth (see `TurnViewModel.timeline`); the aggregate |
| * `assistant` / `assistantThinking` fields are kept only for older consumers |
| * (copy, export, prompt rail). |
| * |
| * - `thinking`: one reasoning block (a step's thinking; adjacent blocks are |
| * pre-merged with `\n\n`). Rendered as a collapsed "深度思考" disclosure. |
| * - `text`: one assistant answer segment (a step's text). `ts` is the source |
| * step's wall-clock for hover meta. |
| * - `tools`: one contiguous group of tool activity, rendered as a single |
| * Astryx tool group. Adjacent groups are pre-merged. |
| * |
| * The model stays FLAT: the collapsed "Processing" fold (#1307) is a render |
| * concern applied by `foldTimeline` (timeline-fold.ts) at the component layer, |
| * so timeline-rewriting passes (overlayLiveTurn, projectTurnTools, shell-run |
| * folding) never have to maintain a nesting invariant. |
| */ |
| export type TurnTimelineItem = |
| | { |
| kind: "thinking"; |
| text: string; |
| messageId: string; |
| live?: boolean; |
| truncated?: boolean; |
| } |
| | { |
| kind: "text"; |
| text: string; |
| messageId: string; |
| ts?: number; |
| live?: boolean; |
| complete?: boolean; |
| truncated?: boolean; |
| } |
| | { kind: "tools"; items: ToolActivityItem[] }; |
| |
| /** |
| * A single conversational turn — typically one user message, the assistant's |
| * tool calls (if any), and the assistant's final answer. Derived as a |
| * read-only projection from `messages` + live tools (no storage changes |
| * needed — every StoredMessage already carries a `turnId`). |
| * |
| * Per @kenji UI-04 (turn narrative): replaces the previous "message stack |
| * + tools panel at end" layout with a per-turn rendering so a single user |
| * → assistant exchange reads as one work unit instead of fragments. |
| */ |
| export interface TurnViewModel { |
| turnId: string; |
| status: TurnStatus; |
| /** |
| * See `TurnRecord.statusSource` — whether `status` is evidence or a reading. |
| * Absent on hand-built view models, which are treated as non-evidence. |
| */ |
| statusSource?: TurnRecord["statusSource"]; |
| parentTurnId?: string; |
| retriedFromTurnId?: string; |
| regeneratedFromTurnId?: string; |
| branchOfTurnId?: string; |
| parentSessionId?: string; |
| abortedAt?: number; |
| abortSource?: string; |
| errorClass?: string; |
| partialOutputRetained: boolean; |
| user?: ChatItem; |
| /** User instructions inserted while this turn was already running. */ |
| userInterjections?: ChatItem[]; |
| tools: ToolActivityItem[]; |
| assistant?: ChatItem; |
| /** |
| * Anthropic-style reasoning that some providers expose alongside the |
| * assistant's final answer. Rendered in a collapsed `<details>` so the |
| * answer reads cleanly but the thinking is one click away when the |
| * user wants to verify the chain of reasoning. |
| */ |
| assistantThinking?: string; |
| /** |
| * Interleaved thinking / answer / tool sequence in production order — the |
| * rendering source of truth for the turn body. Built from the per-step |
| * assistant rows and each step's paired tools (see buildTurnTimeline). |
| */ |
| timeline: TurnTimelineItem[]; |
| /** System notes inside this turn that survive the VISIBLE_SYSTEM_NOTES gate. */ |
| notes: ChatItem[]; |
| /** Wall-clock ts of the earliest message in this turn — used for sorting. */ |
| startedAt: number; |
| /** Model id from the assistant message (if any), e.g. claude-sonnet-4-5. */ |
| modelId?: string; |
| /** Wall-clock ms between earliest user/tool message and assistant message. */ |
| durationMs?: number; |
| /** Token totals summed across all `token_usage` messages within the turn. */ |
| tokens?: { |
| input: number; |
| output: number; |
| cacheMiss?: number; |
| cacheRead?: number; |
| cacheCreation?: number; |
| reasoning?: number; |
| costUsd?: number; |
| }; |
| } |
| |
| export function overlayLiveTurn( |
| turns: readonly TurnViewModel[], |
| liveTurn: LiveTurnProjection | undefined, |
| ): readonly TurnViewModel[] { |
| if (!liveTurn) return turns; |
| const targetIndex = turns.findIndex( |
| (turn) => turn.turnId === liveTurn.turnId, |
| ); |
| if ( |
| targetIndex >= 0 |
| && liveTurn.steps.length === 0 |
| && (liveTurn.steering?.length ?? 0) === 0 |
| ) { |
| return turns; |
| } |
| const current = |
| targetIndex >= 0 |
| ? turns[targetIndex]! |
| : ({ |
| turnId: liveTurn.turnId, |
| status: "completed" as const, |
| partialOutputRetained: false, |
| tools: [], |
| notes: [], |
| timeline: [], |
| startedAt: Date.now(), |
| } satisfies TurnViewModel); |
| // Only a recorded turn_state is evidence the turn ended; a legacy turn's |
| // inferred `completed` is a guess, and such a turn cannot be live anyway. |
| const turnRecordedAsEnded = |
| current.statusSource === "recorded" && current.status !== "running"; |
| const toolByUseId = new Map( |
| current.tools.map((tool) => [tool.toolUseId, tool]), |
| ); |
| const liveToolIds = new Set<string>(); |
| const liveContentKeys = new Set<string>(); |
| for (const step of liveTurn.steps) { |
| if (step.thinking) liveContentKeys.add(`thinking\0${step.stepId}`); |
| if (step.text) liveContentKeys.add(`text\0${step.stepId}`); |
| for (const liveTool of step.tools) { |
| liveToolIds.add(liveTool.toolUseId); |
| const persisted = toolByUseId.get(liveTool.toolUseId); |
| toolByUseId.set( |
| liveTool.toolUseId, |
| persisted |
| ? mergeLiveOverPersisted(persisted, liveTool, turnRecordedAsEnded) |
| : liveTool, |
| ); |
| } |
| } |
| const timeline: TurnTimelineItem[] = []; |
| for (const item of current.timeline) { |
| if (item.kind !== "tools") { |
| if (liveContentKeys.has(`${item.kind}\0${item.messageId}`)) continue; |
| timeline.push(item); |
| continue; |
| } |
| const settledItems = item.items.filter( |
| (tool) => !liveToolIds.has(tool.toolUseId), |
| ); |
| if (settledItems.length > 0) |
| timeline.push({ kind: "tools", items: settledItems }); |
| } |
| for (const step of liveTurn.steps) { |
| const contentOrder = step.contentOrder ?? [ |
| ...(step.thinking ? ["thinking" as const] : []), |
| ...(step.text ? ["text" as const] : []), |
| ...(step.tools.length > 0 ? ["tools" as const] : []), |
| ]; |
| for (const kind of contentOrder) { |
| if (kind === "thinking" && step.thinking?.text) { |
| timeline.push({ |
| kind: "thinking", |
| text: step.thinking.text, |
| messageId: step.stepId, |
| live: step.thinking.complete !== true, |
| truncated: step.thinking.truncated, |
| }); |
| } else if (kind === "text" && step.text?.text) { |
| timeline.push({ |
| kind: "text", |
| text: step.text.text, |
| messageId: step.stepId, |
| live: true, |
| complete: step.text.complete, |
| truncated: step.text.truncated, |
| }); |
| } else if (kind === "tools") { |
| const stepTools = step.tools.flatMap((tool) => { |
| const projected = toolByUseId.get(tool.toolUseId); |
| return projected ? [projected] : []; |
| }); |
| if (stepTools.length > 0) |
| timeline.push({ kind: "tools", items: stepTools }); |
| } |
| } |
| } |
| const mergedTimeline = mergeAdjacentTimeline(timeline); |
| const persistedUserIds = new Set([ |
| ...(current.user ? [current.user.id] : []), |
| ...(current.userInterjections ?? []).map((message) => message.id), |
| ]); |
| const userInterjections = [ |
| ...(current.userInterjections ?? []), |
| ...(liveTurn.steering ?? []).flatMap((message) => |
| persistedUserIds.has(message.id) |
| ? [] |
| : [{ |
| id: message.id, |
| role: "user" as const, |
| text: message.text, |
| ts: message.ts, |
| }], |
| ), |
| ]; |
| const next = { |
| ...current, |
| ...(userInterjections.length > 0 ? { userInterjections } : {}), |
| tools: timelineTools(mergedTimeline), |
| timeline: mergedTimeline, |
| }; |
| const overlaid = |
| targetIndex < 0 |
| ? [...turns, next] |
| : turns.map((turn, index) => (index === targetIndex ? next : turn)); |
| return foldShellRunTurns(overlaid); |
| } |
| |
| /** |
| * The display state a shell-run update contributes to its owning tool: the |
| * merged result plus the ownership badge. Folded from the raw update list once |
| * per update change, so the per-tool application below sees one entry per tool |
| * rather than the whole update history. |
| */ |
| export interface ShellRunOverlayEntry { |
| result: Extract<ToolResultContent, { kind: "shell_run" }>; |
| source: ToolActivityItem["shellRunSource"]; |
| } |
| |
| export function foldShellRunUpdates( |
| updates: readonly ShellRunUpdate[], |
| ): ReadonlyMap<string, ShellRunOverlayEntry> { |
| const byToolUseId = new Map<string, ShellRunOverlayEntry>(); |
| for (const update of updates) { |
| const current = byToolUseId.get(update.sourceToolCallId); |
| const merged = mergeShellRunStateWithDiagnostics( |
| current?.result, |
| update.result, |
| "ui.overlay-shell-run-updates", |
| ); |
| byToolUseId.set(update.sourceToolCallId, { |
| result: merged.result, |
| source: |
| !isActiveShellRunStatus(merged.result.status) || |
| update.ownership.kind === "local" |
| ? undefined |
| : update.ownership.kind === "source_owned" |
| ? "owned" |
| : "unavailable", |
| }); |
| } |
| return byToolUseId; |
| } |
| |
| /** |
| * Apply one folded update to the tool that owns it. Returns the SAME tool when |
| * the update says nothing new, so a caller that holds the previous output can |
| * tell "nothing changed" from object identity. |
| * |
| * A durable update's revision permanently leads the `tool_result` snapshot |
| * persisted in messages, so against the persisted tool this returns a fresh |
| * object every time. Identity for that case is re-established downstream by |
| * value — see `reconcileTurnIdentities`. |
| */ |
| export function applyShellRunOverlayEntry( |
| tool: ToolActivityItem, |
| entry: ShellRunOverlayEntry, |
| ): ToolActivityItem { |
| if (tool.toolName !== "Bash") return tool; |
| const current = tool.result?.kind === "shell_run" ? tool.result : undefined; |
| if (tool.result && !current) return tool; |
| const merged = mergeShellRunStateWithDiagnostics( |
| current, |
| entry.result, |
| "ui.overlay-shell-run-update", |
| ); |
| return merged.changed || tool.shellRunSource !== entry.source |
| ? { ...tool, result: merged.result, shellRunSource: entry.source } |
| : tool; |
| } |
| |
| /** |
| * Group materialized chat + tool items by `turnId` into ordered turns. Items |
| * without a turnId (e.g. fake-backend echo, or older sessions) fall into a |
| * synthetic `__loose` bucket rendered first so they remain visible. |
| */ |
| export function materializeTurns( |
| messages: readonly StoredMessage[], |
| ): TurnViewModel[] { |
| const turnRecords = deriveTurnRecords(messages); |
| const turnRecordById = new Map( |
| turnRecords.map((turn) => [turn.turnId, turn]), |
| ); |
| const order: string[] = []; |
| const byId = new Map<string, TurnViewModel>(); |
| const looseTurnId = "__loose"; |
| // Storage-ordered messages per turn — the raw sequence the timeline pass |
| // replays to interleave a step's thinking/text with its paired tools. |
| const messagesByTurn = new Map<string, StoredMessage[]>(); |
| |
| function ensureTurn(turnId: string, startedAt: number): TurnViewModel { |
| let turn = byId.get(turnId); |
| if (!turn) { |
| const record = turnRecordById.get(turnId); |
| turn = { |
| turnId, |
| status: record?.status ?? "completed", |
| statusSource: record?.statusSource ?? "inferred", |
| ...(record?.parentTurnId ? { parentTurnId: record.parentTurnId } : {}), |
| ...(record?.retriedFromTurnId |
| ? { retriedFromTurnId: record.retriedFromTurnId } |
| : {}), |
| ...(record?.regeneratedFromTurnId |
| ? { regeneratedFromTurnId: record.regeneratedFromTurnId } |
| : {}), |
| ...(record?.branchOfTurnId |
| ? { branchOfTurnId: record.branchOfTurnId } |
| : {}), |
| ...(record?.parentSessionId |
| ? { parentSessionId: record.parentSessionId } |
| : {}), |
| ...(record?.abortedAt !== undefined |
| ? { abortedAt: record.abortedAt } |
| : {}), |
| ...(record?.abortSource ? { abortSource: record.abortSource } : {}), |
| ...(record?.errorClass ? { errorClass: record.errorClass } : {}), |
| partialOutputRetained: record?.partialOutputRetained ?? false, |
| tools: [], |
| notes: [], |
| timeline: [], |
| startedAt, |
| }; |
| byId.set(turnId, turn); |
| order.push(turnId); |
| } else if (startedAt < turn.startedAt) { |
| turn.startedAt = startedAt; |
| } |
| return turn; |
| } |
| |
| // First pass: assign each message to its turn and walk chat-relevant |
| // messages into the projection. |
| for (const message of messages) { |
| const turnId = (message as { turnId?: string }).turnId ?? looseTurnId; |
| const ts = (message as { ts?: number }).ts ?? 0; |
| const turn = ensureTurn(turnId, ts); |
| const turnMessageList = messagesByTurn.get(turnId); |
| if (turnMessageList) turnMessageList.push(message); |
| else messagesByTurn.set(turnId, [message]); |
| if (message.type === "user") { |
| const user: ChatItem = { |
| id: message.id, |
| role: "user", |
| text: message.displayText ?? message.text, |
| ts: message.ts, |
| ...(message.attachments && message.attachments.length > 0 |
| ? { attachments: message.attachments } |
| : {}), |
| ...(message.quotes && message.quotes.length > 0 |
| ? { quotes: message.quotes } |
| : {}), |
| ...(message.inlineReferences !== undefined |
| ? { inlineReferences: message.inlineReferences } |
| : {}), |
| ...(message.origin ? { hostOrigin: message.origin } : {}), |
| }; |
| if (!turn.user) { |
| turn.user = user; |
| } else { |
| turn.userInterjections = [...(turn.userInterjections ?? []), user]; |
| } |
| } else if (message.type === "assistant") { |
| // A turn now holds one AssistantMessage per model step. Concatenate their |
| // text (and thinking) in step order so the turn reads as one answer; keep |
| // the first step's id as the stable anchor, and advance ts to the latest |
| // step so durationMs measures to the turn's final assistant message. |
| const priorText = turn.assistant?.text ?? ""; |
| const mergedText = |
| message.text.length > 0 |
| ? priorText.length > 0 |
| ? `${priorText}\n\n${message.text}` |
| : message.text |
| : priorText; |
| turn.assistant = { |
| id: turn.assistant?.id ?? message.id, |
| role: "assistant", |
| text: mergedText, |
| ts: message.ts, |
| }; |
| turn.modelId = message.modelId; |
| if (message.thinking?.text) { |
| turn.assistantThinking = turn.assistantThinking |
| ? `${turn.assistantThinking}\n\n${message.thinking.text}` |
| : message.thinking.text; |
| } |
| // Time-to-answer measured from the earliest message in this turn (usually |
| // the user's send) to the turn's final assistant message ts. Tool runs are |
| // inside this window, so the same metric captures both LLM latency and tool |
| // wall-time. We only compute this once an assistant message lands, so a |
| // streaming turn stays at undefined ("进行中" per kenji's PR82 review) |
| // instead of ticking up against the current clock and forcing visible |
| // re-renders. Recomputed as each step lands, so it ends at the last step. |
| if (message.ts !== undefined && message.ts >= turn.startedAt) { |
| turn.durationMs = message.ts - turn.startedAt; |
| } |
| } else if ( |
| message.type === "system_note" && |
| VISIBLE_SYSTEM_NOTES.has(message.kind) |
| ) { |
| turn.notes.push({ |
| id: message.id, |
| role: "system", |
| text: SYSTEM_NOTE_LABELS[message.kind] ?? message.kind, |
| ts: message.ts, |
| }); |
| } else if (message.type === "token_usage") { |
| const totals = turn.tokens ?? { input: 0, output: 0 }; |
| totals.input += message.input; |
| totals.output += message.output; |
| if (message.cacheMissInput !== undefined) |
| totals.cacheMiss = (totals.cacheMiss ?? 0) + message.cacheMissInput; |
| if (message.cacheRead !== undefined) |
| totals.cacheRead = (totals.cacheRead ?? 0) + message.cacheRead; |
| if (message.cacheCreation !== undefined) |
| totals.cacheCreation = |
| (totals.cacheCreation ?? 0) + message.cacheCreation; |
| if (message.reasoning !== undefined) |
| totals.reasoning = (totals.reasoning ?? 0) + message.reasoning; |
| if (message.costUsd !== undefined) |
| totals.costUsd = (totals.costUsd ?? 0) + message.costUsd; |
| turn.tokens = totals; |
| } |
| } |
| |
| // Second pass: build the canonical tool map. Live tools are applied |
| // separately by overlayLiveTurn so streaming deltas never force settled |
| // history to rematerialize. |
| const toolItemByUseId = new Map<string, ToolActivityItem>( |
| foldShellRunToolActivities(materializeTools(messages)).map((tool) => [ |
| tool.toolUseId, |
| tool, |
| ]), |
| ); |
| // Third pass: rebuild each turn's render timeline from its storage-ordered |
| // messages, interleaving a step's thinking/text with its paired tools. The |
| // timeline is the turn's only tool authority; `tools` is flattened out of it |
| // so the two can never disagree about which tools a turn holds (a tool_call |
| // row always lands in its own turn's message list, so every surviving tool |
| // reaches exactly one timeline). |
| for (const turnId of order) { |
| const turn = byId.get(turnId)!; |
| turn.timeline = buildTurnTimeline( |
| messagesByTurn.get(turnId) ?? [], |
| toolItemByUseId, |
| ); |
| turn.tools = timelineTools(turn.timeline); |
| } |
| |
| return order.map((turnId) => byId.get(turnId)!); |
| } |
| |
| /** |
| * The turn's final reply: the last answer step on the timeline. Intermediate |
| * steps (text emitted between tool calls) narrate the work in progress; the |
| * clipboard wants only the answer the turn settled on (#2407), not the |
| * `\n\n`-joined `assistant.text` aggregate. Falls back to the aggregate for |
| * turns with no timeline text entry. |
| */ |
| export function finalAssistantReplyText(turn: TurnViewModel): string { |
| for (let index = turn.timeline.length - 1; index >= 0; index -= 1) { |
| const item = turn.timeline[index]; |
| if (item?.kind === "text" && item.text.length > 0) return item.text; |
| } |
| return turn.assistant?.text ?? ""; |
| } |
| |
| /** |
| * Fold a background command's child tools (its `Read`s and `StopBackgroundTask`) |
| * into the `Bash` that owns the run. |
| * |
| * Parent lookup is deliberately position-independent. A turn's tools are a |
| * flattening of its timeline, and a live overlay moves that turn's tools to the |
| * end of the timeline — which can order a child ahead of the `Bash` it belongs |
| * to. Scanning only what has been folded so far would silently stop folding |
| * there, leaving an orphan tool row and a parent that never took the child's |
| * revision. |
| */ |
| export function foldShellRunToolActivities( |
| items: readonly ToolActivityItem[], |
| ): ToolActivityItem[] { |
| const ownedRefs = new Set<string>(); |
| for (const item of items) { |
| if (item.toolName === "Bash" && item.result?.kind === "shell_run") |
| ownedRefs.add(item.result.ref); |
| } |
| |
| const folded: ToolActivityItem[] = []; |
| const parentIndexByRef = new Map<string, number>(); |
| const childResultsByRef = new Map<string, ShellRunToolResult[]>(); |
| |
| for (const item of items) { |
| const result = item.result?.kind === "shell_run" ? item.result : undefined; |
| if (!result || item.toolName === "Bash") { |
| if (result) parentIndexByRef.set(result.ref, folded.length); |
| folded.push(item); |
| continue; |
| } |
| if (ownedRefs.has(result.ref)) { |
| const pending = childResultsByRef.get(result.ref); |
| if (pending) pending.push(result); |
| else childResultsByRef.set(result.ref, [result]); |
| if (item.toolName === "Read" || item.toolName === "StopBackgroundTask") |
| continue; |
| } |
| folded.push(item); |
| } |
| |
| for (const [ref, results] of childResultsByRef) { |
| const index = parentIndexByRef.get(ref)!; |
| const parent = folded[index]!; |
| let current = |
| parent.result?.kind === "shell_run" ? parent.result : undefined; |
| let changed = false; |
| for (const result of results) { |
| const merged = mergeShellRunStateWithDiagnostics( |
| current, |
| result, |
| "ui.fold-shell-run-child", |
| ); |
| if (merged.changed) { |
| current = merged.result; |
| changed = true; |
| } |
| } |
| if (changed && current) folded[index] = { ...parent, result: current }; |
| } |
| |
| return folded; |
| } |
| |
| function foldShellRunTurns( |
| turns: readonly TurnViewModel[], |
| ): readonly TurnViewModel[] { |
| return projectTurnTools( |
| turns, |
| foldShellRunToolActivities(turns.flatMap((turn) => turn.tools)), |
| ); |
| } |
| |
| /** Flatten a turn's timeline into its tool list — the one derivation direction. */ |
| export function timelineTools( |
| timeline: readonly TurnTimelineItem[], |
| ): ToolActivityItem[] { |
| return timeline.flatMap((item) => (item.kind === "tools" ? item.items : [])); |
| } |
| |
| /** |
| * Rewrite `turns` against a canonical tool map. The timeline is rebuilt from |
| * the map and `turn.tools` is flattened out of the rebuilt timeline, so there |
| * is one tool authority per turn instead of two structures kept in step. |
| * |
| * Identity-preserving: a turn whose tools all resolve to the objects it |
| * already holds is returned unchanged, which is what lets a memoized TurnView |
| * skip a turn that this pass did not touch. |
| */ |
| export function projectTurnTools( |
| turns: readonly TurnViewModel[], |
| tools: readonly ToolActivityItem[], |
| ): readonly TurnViewModel[] { |
| const projected = new Map(tools.map((tool) => [tool.toolUseId, tool])); |
| return turns.map((turn) => { |
| let changed = false; |
| const timeline = turn.timeline.flatMap<TurnTimelineItem>( |
| (item): TurnTimelineItem[] => { |
| if (item.kind !== "tools") return [item]; |
| const items = item.items.flatMap((tool) => { |
| const projectedTool = projected.get(tool.toolUseId); |
| return projectedTool ? [projectedTool] : []; |
| }); |
| if ( |
| items.length !== item.items.length || |
| items.some((tool, index) => tool !== item.items[index]) |
| ) { |
| changed = true; |
| } |
| return items.length > 0 ? [{ kind: "tools" as const, items }] : []; |
| }, |
| ); |
| if (!changed) return turn; |
| return { ...turn, tools: timelineTools(timeline), timeline }; |
| }); |
| } |
| |
| /** |
| * Rebuild a turn's render timeline from its storage-ordered messages. |
| * |
| * Ledger order within a turn is tool_call(s) -> tool_result(s) -> |
| * assistant(step) -> next step's tools -> ... . Walking that sequence: |
| * |
| * - tool_call rows buffer their (merged) ToolActivityItem into `pending`, |
| * tagged by the item's stepId. |
| * - an assistant row (id === a step's messageId) flushes the buffer around |
| * its own thinking/text. New rows carry `contentOrder`, the first-observed |
| * order recorded by the runtime; older rows retain the historical |
| * thinking -> legacy tools -> text -> matched tools fallback. Tools |
| * whose stepId matches no assistant row are orphans of a pure-tool step |
| * (which persists no assistant message); ledger append order guarantees |
| * they ran BEFORE this row landed, so they flush ahead of this step's |
| * content — parking them past the text would invert the common |
| * "call tools, then summarize next step" turn into answer-then-tools. |
| * - leftover buffered tools (abort / pure-tool turn with no assistant row) |
| * flush as a trailing tools group. |
| * |
| * Empty text/thinking produce no item. Adjacent thinking blocks merge with |
| * a blank line; adjacent tools groups merge into one group. |
| */ |
| function buildTurnTimeline( |
| turnMessages: readonly StoredMessage[], |
| toolItemByUseId: ReadonlyMap<string, ToolActivityItem>, |
| ): TurnTimelineItem[] { |
| const raw: TurnTimelineItem[] = []; |
| let pending: ToolActivityItem[] = []; |
| const flushTools = (items: ToolActivityItem[]): void => { |
| if (items.length > 0) raw.push({ kind: "tools", items }); |
| }; |
| for (const message of turnMessages) { |
| if (message.type === "tool_call") { |
| const item = toolItemByUseId.get(message.id); |
| if (item) pending.push(item); |
| } else if (message.type === "assistant") { |
| const rowId = message.id; |
| const legacy = pending.filter((tool) => tool.stepId === undefined); |
| const matched = pending.filter((tool) => tool.stepId === rowId); |
| // stepId set but not this row's: orphans of an earlier pure-tool step |
| // (no assistant row carries their stepId). A later step's tools cannot |
| // be pending here — the ledger appends them after this assistant row — |
| // so these ran earlier and must render before this step's content. |
| const orphaned = pending.filter( |
| (tool) => tool.stepId !== undefined && tool.stepId !== rowId, |
| ); |
| pending = []; |
| flushTools(orphaned); |
| if (message.contentOrder?.length) { |
| // Legacy calls cannot be associated with a step, so preserve their |
| // old pre-answer position without letting them disturb the recorded |
| // order of this row's own content. |
| flushTools(legacy); |
| const remaining = new Set<"thinking" | "text" | "tools">([ |
| "thinking", |
| "text", |
| "tools", |
| ]); |
| const append = (kind: "thinking" | "text" | "tools"): void => { |
| if (!remaining.delete(kind)) return; |
| if (kind === "thinking" && message.thinking?.text) { |
| raw.push({ |
| kind: "thinking", |
| text: message.thinking.text, |
| messageId: rowId, |
| }); |
| } else if (kind === "text" && message.text.length > 0) { |
| raw.push({ |
| kind: "text", |
| text: message.text, |
| messageId: rowId, |
| ts: message.ts, |
| }); |
| } else if (kind === "tools") { |
| flushTools(matched); |
| } |
| }; |
| for (const kind of message.contentOrder) append(kind); |
| // Malformed or partial metadata must never hide persisted content. |
| for (const kind of ["thinking", "text", "tools"] as const) append(kind); |
| } else { |
| if (message.thinking?.text) { |
| raw.push({ |
| kind: "thinking", |
| text: message.thinking.text, |
| messageId: rowId, |
| }); |
| } |
| flushTools(legacy); |
| if (message.text.length > 0) { |
| raw.push({ |
| kind: "text", |
| text: message.text, |
| messageId: rowId, |
| ts: message.ts, |
| }); |
| } |
| flushTools(matched); |
| } |
| } |
| } |
| flushTools(pending); |
| return mergeAdjacentTimeline(raw); |
| } |
| |
| function mergeAdjacentTimeline( |
| items: readonly TurnTimelineItem[], |
| ): TurnTimelineItem[] { |
| const out: TurnTimelineItem[] = []; |
| for (const item of items) { |
| const last = out[out.length - 1]; |
| if ( |
| item.kind === "thinking" && |
| last?.kind === "thinking" && |
| !item.live && |
| !last.live |
| ) { |
| last.text = `${last.text}\n\n${item.text}`; |
| } else if (item.kind === "tools" && last?.kind === "tools") { |
| last.items = [...last.items, ...item.items]; |
| } else if (item.kind === "tools") { |
| out.push({ kind: "tools", items: [...item.items] }); |
| } else { |
| out.push({ ...item }); |
| } |
| } |
| return out; |
| } |
| |
| export interface TurnLineageTarget { |
| retriedToTurnId?: string; |
| regeneratedToTurnId?: string; |
| } |
| |
| export function deriveTurnLineageMap( |
| turns: readonly Pick< |
| TurnRecord, |
| "turnId" | "retriedFromTurnId" | "regeneratedFromTurnId" |
| >[], |
| ): Map<string, TurnLineageTarget> { |
| const out = new Map<string, TurnLineageTarget>(); |
| for (const turn of turns) { |
| if (turn.retriedFromTurnId) { |
| out.set(turn.retriedFromTurnId, { |
| ...(out.get(turn.retriedFromTurnId) ?? {}), |
| retriedToTurnId: turn.turnId, |
| }); |
| } |
| if (turn.regeneratedFromTurnId) { |
| out.set(turn.regeneratedFromTurnId, { |
| ...(out.get(turn.regeneratedFromTurnId) ?? {}), |
| regeneratedToTurnId: turn.turnId, |
| }); |
| } |
| } |
| return out; |
| } |