| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { deriveTurnRecords, isUserVisibleSessionSystemNote } from '@maka/core/session'; |
| import { foldTimeline } from './timeline-fold.js'; |
| import { |
| isInFlightToolStatus, |
| toolResultActivityStatus, |
| unfinishedToolActivityStatus, |
| } from '@maka/core/tool-result-status'; |
| import { isActiveShellRunStatus } from '@maka/core/shell-run'; |
| import { mergeShellRunStateWithDiagnostics } from '@maka/core/shell-run-result'; |
| import { projectToolActivityArgs } from '@maka/core/tool-activity-args'; |
| import type { |
| AttachmentRef, |
| InlineReference, |
| MessageContent, |
| QuoteRef, |
| ShellRunUpdate, |
| ToolActivityKind, |
| ToolResultContent, |
| ToolStepProgress, |
| } from '@maka/core/events'; |
| import type { ToolActivityStatus } from '@maka/core/tool-result-status'; |
| import type { ShellRunToolResult } from '@maka/core/shell-run-result'; |
| import type { StoredMessage, TurnRecord, TurnStatus, UserMessage } from '@maka/core/session'; |
| import type { UiLocale } from '@maka/core/ui-locale'; |
| import type { |
| LiveSteeringProjection, |
| LiveTurnProjection, |
| } from "./live-turn-projection.js"; |
| import { getConversationCopy } from "./conversation-copy.js"; |
| |
| export { isCancelledToolResultContent, isInFlightToolStatus, toolResultActivityStatus } from '@maka/core/tool-result-status'; |
| |
| export interface ChatItem { |
| compactionState?: "running" | "compacted" | "failed"; |
| 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[]; |
| /** Host-bound directory references projected from StoredMessage; user rows only. */ |
| directoryReferences?: import('@maka/core/events').DirectoryReference[]; |
| /** 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; |
| /** |
| * Live-only bounded/redacted args subset from the Runtime Host wire (full |
| * args arrive with the durable transcript at turn end). Display formatters |
| * read `args ?? argsPreview`; never rendered as raw JSON. |
| */ |
| argsPreview?: unknown; |
| 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; |
| /** Lifecycle of the tool invocation itself, independent of a returned resource. */ |
| status: ToolActivityStatus; |
| args: unknown; |
| result?: ToolResultContent; |
| durationMs?: number; |
| /** Live-only progress for a bounded multi-step tool invocation. */ |
| progress?: ToolStepProgress; |
| /** |
| * 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"; |
| } |
| |
| function systemNoteLabel(kind: string, data: unknown, locale: UiLocale): string { |
| const copy = getConversationCopy(locale).messages.systemNotes; |
| if (kind === "context_compacted") return copy.contextCompacted; |
| if (kind === "context_compaction_failed_open") return copy.contextCompactionFailedOpen; |
| if (kind === "context_provider_dropping") { |
| const dropping = data as { inputTokens?: unknown; priorInputTokens?: unknown } | undefined; |
| const used = typeof dropping?.inputTokens === "number" ? dropping.inputTokens : 0; |
| const prior = typeof dropping?.priorInputTokens === "number" ? dropping.priorInputTokens : 0; |
| return copy.contextProviderDropping(used, prior); |
| } |
| if (kind === "context_overflow_after_compaction") return copy.contextOverflowAfterCompaction; |
| if (kind === "context_reported_window_exceeded") { |
| const exceeded = data as |
| | { usedTokens?: unknown; reportedContextWindow?: unknown } |
| | undefined; |
| const used = typeof exceeded?.usedTokens === "number" ? exceeded.usedTokens : 0; |
| const reported = |
| typeof exceeded?.reportedContextWindow === "number" ? exceeded.reportedContextWindow : 0; |
| return copy.contextReportedWindowExceeded(used, reported); |
| } |
| if (kind === "context_window_overrun") { |
| const overrun = data as |
| | { usedTokens?: unknown; declaredContextWindow?: unknown } |
| | undefined; |
| const used = typeof overrun?.usedTokens === "number" ? overrun.usedTokens : 0; |
| const declared = |
| typeof overrun?.declaredContextWindow === "number" ? overrun.declaredContextWindow : 0; |
| return copy.contextWindowOverrun(used, declared); |
| } |
| if (kind === "context_window_suggestion") { |
| const suggestion = data as |
| | { suggestedContextWindow?: unknown; declaredContextWindow?: unknown } |
| | undefined; |
| const tokens = |
| typeof suggestion?.suggestedContextWindow === "number" |
| ? suggestion.suggestedContextWindow |
| : 0; |
| const declared = |
| typeof suggestion?.declaredContextWindow === "number" |
| ? suggestion.declaredContextWindow |
| : undefined; |
| return copy.contextWindowSuggestion(tokens, declared); |
| } |
| if (kind === "step_limit") return copy.stepLimit; |
| return kind; |
| } |
| |
| export function materializeChat( |
| messages: readonly StoredMessage[], |
| locale: UiLocale, |
| ): 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.directoryReferences ? { directoryReferences: message.directoryReferences } : {}), |
| ...(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" && |
| isUserVisibleSessionSystemNote(message.kind) |
| ) { |
| items.push({ |
| id: message.id, |
| role: "system", |
| text: systemNoteLabel(message.kind, message.data, locale), |
| compactionState: message.kind === "context_compacted" ? "compacted" : message.kind === "context_compaction_failed_open" ? "failed" : undefined, |
| 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; |
| } |
| if (persisted.result !== undefined && live.result === undefined) { |
| // `applyLiveTurnEvent` removes deliberately omitted payloads before this |
| // merge. Only absence asks durable state to fill the result; an explicit |
| // empty result is still meaningful newer evidence. |
| 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 — interleaved thinking, answer, tool, |
| * and mid-turn user messages in conversational order. This is the |
| * rendering source of truth (see `TurnViewModel.timeline`); the aggregate |
| * `assistant` / `assistantThinking` fields are legacy aggregates; they do not |
| * identify the reply used by rendering, copy or prompt rail previews. |
| * |
| * - `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. Adjacent groups are |
| * pre-merged; presentation may split ordinary evidence and linked-session |
| * navigation into adjacent native Astryx segments without reordering them. |
| * - `user`: an instruction inserted after the turn began, displayed where |
| * Runtime acknowledged it. |
| * |
| * 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: "user"; |
| message: ChatItem; |
| messageId: string; |
| steeringEventId?: string; |
| } |
| | { |
| kind: "thinking"; |
| text: string; |
| messageId: string; |
| live?: boolean; |
| truncated?: boolean; |
| } |
| | { |
| kind: "text"; |
| interrupted?: true; |
| 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; |
| failureMessage?: string; |
| retry?: import('@maka/core/model-failure').ModelRetryDecision; |
| user?: 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 / steering 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, |
| locale: UiLocale, |
| ): readonly TurnViewModel[] { |
| if (!liveTurn) return turns; |
| const targetIndex = turns.findIndex( |
| (turn) => turn.turnId === liveTurn.turnId, |
| ); |
| // A running host-owned context-compaction Turn emits no assistant content. |
| // The Runtime persists a `turn_state:running` row for it, so a settled turn |
| // with this turnId usually already exists (empty). Surface a single |
| // "compacting" system row: merge the note into that existing turn, or |
| // synthesize one if it has not settled yet. The note is deduped by id so |
| // reprojection stays idempotent, and it disappears when the Turn settles |
| // (the live projection drops to undefined and the durable `context_compacted` |
| // note takes over). |
| if (liveTurn.rootExecutionKind === "context_compact" && liveTurn.steps.length === 0) { |
| const noteId = `context-compaction:${liveTurn.turnId}`; |
| if (targetIndex >= 0) { |
| const existing = turns[targetIndex]!; |
| if (existing.notes.some((note) => note.id === noteId)) return turns; |
| const note: ChatItem = { |
| id: noteId, |
| role: "system", |
| text: getConversationCopy(locale).messages.systemNotes.contextCompacting, |
| compactionState: "running", |
| ts: existing.startedAt, |
| }; |
| return turns.map((turn, index) => |
| index === targetIndex ? { ...turn, notes: [...turn.notes, note] } : turn, |
| ); |
| } |
| const startedAt = liveTurn.startedAt ?? 0; |
| return [ |
| ...turns, |
| { |
| turnId: liveTurn.turnId, |
| status: "running" as const, |
| tools: [], |
| notes: [ |
| { |
| id: noteId, |
| role: "system", |
| text: getConversationCopy(locale).messages.systemNotes.contextCompacting, |
| compactionState: "running", |
| ts: startedAt, |
| }, |
| ], |
| timeline: [], |
| startedAt, |
| } satisfies TurnViewModel, |
| ]; |
| } |
| if ( |
| targetIndex >= 0 |
| && liveTurn.steps.length === 0 |
| && (liveTurn.pendingSteering?.length ?? 0) === 0 |
| ) { |
| return turns; |
| } |
| // A send arm is only a presentation claim that the next message may still |
| // arrive. It is not a Turn record and must not manufacture one while the |
| // canonical transcript is catching up. A real live step (or steering |
| // message) is sufficient evidence to project a missing external Turn. |
| if ( |
| targetIndex < 0 |
| && liveTurn.steps.length === 0 |
| && (liveTurn.pendingSteering?.length ?? 0) === 0 |
| ) { |
| return turns; |
| } |
| const current = |
| targetIndex >= 0 |
| ? turns[targetIndex]! |
| : ({ |
| turnId: liveTurn.turnId, |
| status: "completed" as const, |
| 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 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) { |
| const persisted = toolByUseId.get(liveTool.toolUseId); |
| toolByUseId.set( |
| liveTool.toolUseId, |
| persisted |
| ? mergeLiveOverPersisted(persisted, liveTool, turnRecordedAsEnded) |
| : liveTool, |
| ); |
| } |
| } |
| const liveTimeline: TurnTimelineItem[] = []; |
| const emittedSteeringIds = new Set<string>(); |
| const appendLiveSteering = ( |
| messages: readonly LiveSteeringProjection[], |
| ): void => { |
| for (const message of messages) { |
| if (emittedSteeringIds.has(message.id)) continue; |
| emittedSteeringIds.add(message.id); |
| liveTimeline.push({ |
| kind: "user", |
| message: chatItemFromContent(message.id, message.ts, message.content), |
| messageId: message.id, |
| }); |
| } |
| }; |
| for (const step of liveTurn.steps) { |
| appendLiveSteering(step.leadingSteering ?? []); |
| 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) { |
| liveTimeline.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 && (step.text.text || step.text.interrupted)) { |
| liveTimeline.push({ |
| kind: "text", |
| text: step.text.text, |
| ...(step.text.interrupted ? { interrupted: true } : {}), |
| 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) |
| liveTimeline.push({ kind: "tools", items: stepTools }); |
| } |
| } |
| } |
| appendLiveSteering(liveTurn.pendingSteering ?? []); |
| // Shared entries are handoff points: replace them in place while preserving |
| // live production order. Appending all live content after settled rows moved |
| // an earlier answer (and its steering anchor) behind later persisted steps. |
| const liveEntries = flattenTimelineTools(liveTimeline); |
| const liveIndex = new Map(liveEntries.map((item, index) => [timelineItemKey(item), index])); |
| const timeline: TurnTimelineItem[] = []; |
| let nextLive = 0; |
| const appendLiveThrough = (index: number) => { |
| while (nextLive <= index) timeline.push(liveEntries[nextLive++]!); |
| }; |
| const lastSettledContentIndex = current.timeline.findLastIndex((item) => item.kind !== 'user'); |
| const deferredSteering: TurnTimelineItem[] = []; |
| for (const [index, item] of current.timeline.entries()) { |
| if (item.kind === 'user' && item.steeringEventId !== undefined |
| && !liveIndex.has(timelineItemKey(item)) && index > lastSettledContentIndex) { |
| deferredSteering.push(item); |
| continue; |
| } |
| for (const entry of flattenTimelineTools([item])) { |
| const key = timelineItemKey(entry); |
| const livePosition = liveIndex.get(key); |
| if (livePosition !== undefined) appendLiveThrough(livePosition); |
| else if (!liveContentKeys.has(key)) timeline.push(entry); |
| } |
| } |
| appendLiveThrough(liveEntries.length - 1); |
| timeline.push(...deferredSteering); |
| const mergedTimeline = mergeAdjacentTimeline(timeline); |
| const next = { |
| ...current, |
| 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", |
| ); |
| const acceptedOwnership = merged.result.revision === update.result.revision; |
| byToolUseId.set(update.sourceToolCallId, { |
| result: merged.result, |
| source: acceptedOwnership |
| ? ( |
| !isActiveShellRunStatus(merged.result.status) || |
| update.ownership.kind === "local" |
| ? undefined |
| : update.ownership.kind === "source_owned" |
| ? "owned" |
| : "unavailable" |
| ) |
| : current?.source, |
| }); |
| } |
| 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", |
| ); |
| const source = merged.result.revision === entry.result.revision |
| ? entry.source |
| : tool.shellRunSource; |
| return merged.changed || tool.shellRunSource !== source |
| ? { ...tool, result: merged.result, shellRunSource: source } |
| : tool; |
| } |
| |
| /** Presentation is derived from invocation and resource facts, never persisted as another state. */ |
| export function toolActivityPresentationStatus(item: ToolActivityItem): ToolActivityStatus { |
| if (item.status === "errored") return "errored"; |
| if (item.toolName === "Bash" && item.result?.kind === "shell_run") { |
| return SHELL_RUN_PRESENTATION_STATUS[item.result.status]; |
| } |
| return item.status; |
| } |
| |
| const SHELL_RUN_PRESENTATION_STATUS = { |
| starting: "running", |
| running: "running", |
| completed: "completed", |
| cancelled: "interrupted", |
| failed: "errored", |
| timed_out: "errored", |
| orphaned: "errored", |
| } as const satisfies Record< |
| Extract<ToolResultContent, { kind: "shell_run" }>["status"], |
| ToolActivityStatus |
| >; |
| |
| /** |
| * 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[], |
| locale: UiLocale, |
| ): 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?.failureMessage ? { failureMessage: record.failureMessage } : {}), |
| ...(record?.errorClass ? { errorClass: record.errorClass } : {}), |
| ...(record?.retry ? { retry: record.retry } : {}), |
| 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 = chatItemFromUserMessage(message); |
| if (!turn.user) { |
| turn.user = 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" && |
| isUserVisibleSessionSystemNote(message.kind) |
| ) { |
| turn.notes.push({ |
| id: message.id, |
| role: "system", |
| text: systemNoteLabel(message.kind, message.data, locale), |
| compactionState: message.kind === "context_compacted" ? "compacted" : message.kind === "context_compaction_failed_open" ? "failed" : undefined, |
| 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)!); |
| } |
| |
| /** |
| * Copy, action availability and previews consume the same reply identity as |
| * the turn body. Process commentary and answers before a new user instruction |
| * must not become the current reply. The aggregate is not an answer fallback. |
| */ |
| export function finalAssistantReplyText(turn: TurnViewModel): string { |
| return foldTimeline(turn.timeline).finalReply?.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, except text carrying an interruption |
| * divider. 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[] = []; |
| let sawUser = false; |
| const flushTools = (items: ToolActivityItem[]): void => { |
| if (items.length > 0) raw.push({ kind: "tools", items }); |
| }; |
| for (const message of turnMessages) { |
| if (message.type === "user") { |
| if (!sawUser) { |
| sawUser = true; |
| continue; |
| } |
| flushTools(pending); |
| pending = []; |
| raw.push({ |
| kind: "user", |
| message: chatItemFromUserMessage(message), |
| messageId: message.id, |
| ...(message.steeringEventId ? { steeringEventId: message.steeringEventId } : {}), |
| }); |
| } else 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 || message.interrupted)) { |
| raw.push({ |
| kind: "text", |
| text: message.text, |
| ...(message.interrupted ? { interrupted: true } : {}), |
| 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 || message.interrupted) { |
| raw.push({ |
| kind: "text", |
| text: message.text, |
| ...(message.interrupted ? { interrupted: true } : {}), |
| messageId: rowId, |
| ts: message.ts, |
| }); |
| } |
| flushTools(matched); |
| } |
| } |
| } |
| flushTools(pending); |
| return mergeAdjacentTimeline(raw); |
| } |
| |
| function chatItemFromUserMessage(message: UserMessage): ChatItem { |
| return chatItemFromContent(message.id, message.ts, message, message.origin); |
| } |
| |
| function chatItemFromContent( |
| id: string, |
| ts: number, |
| content: MessageContent, |
| hostOrigin?: NonNullable<UserMessage["origin"]>, |
| ): ChatItem { |
| return { |
| id, |
| role: "user", |
| text: content.displayText ?? content.text, |
| ts, |
| ...(content.attachments && content.attachments.length > 0 |
| ? { attachments: content.attachments } |
| : {}), |
| ...(content.quotes && content.quotes.length > 0 |
| ? { quotes: content.quotes } |
| : {}), |
| ...(content.directoryReferences ? { directoryReferences: content.directoryReferences } : {}), |
| ...(content.inlineReferences !== undefined |
| ? { inlineReferences: content.inlineReferences } |
| : {}), |
| ...(hostOrigin ? { hostOrigin } : {}), |
| }; |
| } |
| |
| function timelineItemKey(item: TurnTimelineItem): string { |
| return item.kind === 'tools' ? `tool\0${item.items[0]!.toolUseId}` : `${item.kind}\0${item.messageId}`; |
| } |
| |
| function flattenTimelineTools(items: readonly TurnTimelineItem[]): TurnTimelineItem[] { |
| return items.flatMap<TurnTimelineItem>((item) => item.kind === 'tools' |
| ? item.items.map((tool) => ({ kind: 'tools' as const, items: [tool] })) |
| : [item]); |
| } |
| |
| 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; |
| } |