dsh-code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.en.md +287 -286
  2. package/README.md +16 -13
  3. package/bin/deepseek.mjs +336 -11
  4. package/cordis.patch.yml +26 -18
  5. package/lib/index.mjs +1310 -439
  6. package/lib/types/app.d.ts +25 -6
  7. package/lib/types/attachments.d.ts +36 -4
  8. package/lib/types/git-workflow.d.ts +7 -2
  9. package/lib/types/history.d.ts +18 -11
  10. package/lib/types/index.d.ts +11 -2
  11. package/lib/types/presets.d.ts +4 -1
  12. package/lib/types/provider-settings.d.ts +6 -11
  13. package/lib/types/questions.d.ts +16 -12
  14. package/lib/types/render/animations.d.ts +74 -7
  15. package/lib/types/render/export.d.ts +0 -6
  16. package/lib/types/render/fuzzy.d.ts +21 -0
  17. package/lib/types/render/projection.d.ts +47 -5
  18. package/lib/types/session-directory.d.ts +48 -13
  19. package/lib/types/settings-file.d.ts +8 -0
  20. package/lib/types/store.d.ts +3 -0
  21. package/package.json +168 -159
  22. package/src/app.ts +480 -199
  23. package/src/attachments.ts +110 -11
  24. package/src/commands.ts +35 -5
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +1868 -1752
  28. package/src/internals.ts +61 -40
  29. package/src/permissions.ts +1 -1
  30. package/src/presets.ts +19 -6
  31. package/src/provider-settings.ts +12 -12
  32. package/src/questions.ts +57 -74
  33. package/src/render/animations.ts +606 -403
  34. package/src/render/export.ts +20 -10
  35. package/src/render/fuzzy.ts +83 -0
  36. package/src/render/projection.ts +1833 -1620
  37. package/src/session-directory.ts +94 -16
  38. package/src/settings-file.ts +38 -6
  39. package/src/skills.ts +23 -9
  40. package/src/store.ts +39 -1
  41. package/src/subagents.ts +26 -3
@@ -1,1620 +1,1833 @@
1
- /**
2
- * Pure session-event-to-view projection for the TUI transcript: one reducer
3
- * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
- * Rendering never reads the session directly — this module owns the view
5
- * model, so tests drive it with plain event arrays.
6
- *
7
- * @module @deepseek-ai/dsh-tui/render/projection
8
- */
9
-
10
- import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
- import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
- import { graphemeWidth, splitGraphemes } from './width.ts'
13
- // Type-only imports merge the plugin-owned SessionEventMap variants
14
- // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
15
- // plan/mode, permission/preset, sandbox/mode, session/title) into the union
16
- // this reducer switches on.
17
- import type {} from '@deepseek-ai/dsh-agent'
18
- import type {} from '@deepseek-ai/dsh-commands'
19
- import type {} from '@deepseek-ai/dsh-compaction'
20
- import type {} from '@deepseek-ai/dsh-goal'
21
- import type {} from '@deepseek-ai/dsh-llm-retry'
22
- import type {} from '@deepseek-ai/dsh-plan-mode'
23
- import type {} from '@deepseek-ai/dsh-permission-presets'
24
- import type {} from '@deepseek-ai/dsh-sandbox-policy'
25
- import type {} from '@deepseek-ai/dsh-session-title'
26
- import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
27
- import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
28
-
29
- /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
30
- const MAX_STREAMING_CHARS = 65_536
31
-
32
- /**
33
- * Upper bound on remembered `compaction/summary` shadow prices waiting for a
34
- * matching `compaction/end`. Compactions are sequential and rare, so a few
35
- * slots suffice; an aborted compaction (summary without end) otherwise leaves
36
- * an unbounded residue in `anchors.compactionTokens`. An evicted price
37
- * degrades to the documented `lastPruneTokens` fallback, exactly like a
38
- * missing summary.
39
- */
40
- const MAX_COMPACTION_SUMMARY_RESIDUE = 16
41
-
42
- /** Append one delta without retaining an unbounded duplicate of the live reply. */
43
- function appendStreamingTail(current: string, delta: string): string {
44
- const next = current + delta
45
- return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
46
- }
47
-
48
- /** One user prompt line. */
49
- export interface UserEntry {
50
- kind: 'user'
51
- /** Joined text blocks of the user message. */
52
- text: string
53
- /** True for collapsed injected context (plugin/continuation notices), which
54
- * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
55
- notice: boolean
56
- /** Durable image references carried by this prompt. */
57
- images?: readonly ImageBlock['attachment'][]
58
- }
59
-
60
- /** One user message waiting in the agent inbox (the web's queued-message row). */
61
- export interface PendingEntry {
62
- kind: 'pending'
63
- /** Stable message identity shared with the durable `user/message` that retires it. */
64
- messageId: MessageId
65
- /** Which inbox list holds the message: steering is consumed at the next step boundary. */
66
- target: 'next-turn' | 'next-step'
67
- /** Full message text Codex PendingSteer renders queued prompts exactly like user rows. */
68
- text: string
69
- /** Durable image references queued with this prompt. */
70
- images?: readonly ImageBlock['attachment'][]
71
- }
72
-
73
- /** One authoritative assembled assistant reply. */
74
- export interface AssistantEntry {
75
- kind: 'assistant'
76
- /** Joined text blocks of the assistant message. */
77
- text: string
78
- /** Joined reasoning blocks from the same assembled message. */
79
- reasoning: string
80
- /** True when a cancelled stream's delivered prefix was finalized as this
81
- * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
82
- interrupted?: true
83
- }
84
-
85
- /** One model-requested tool invocation and its settled state. */
86
- export interface ToolEntry {
87
- kind: 'tool'
88
- /** Correlation id shared with the matching `tool/result`. */
89
- callId: string
90
- /**
91
- * Global tool-call ordinal across the whole transcript (1, 2, 3…, never
92
- * reset between turns). The tool-card badge and every error line that
93
- * references the failed call share this number, so "call N" in an error
94
- * always names the exact card the badge shows.
95
- */
96
- ordinal: number
97
- /** Tool name as the model addressed it. */
98
- name: string
99
- /** Raw arguments JSON string exactly as the model produced it. */
100
- arguments: string
101
- /** Bounded human-meaningful arguments preview for the tool card. */
102
- preview: string
103
- /** Bounded delegation prompt (subagent cards' second row), '' when none. */
104
- prompt: string
105
- /** Execution state; `running` until the paired result lands. */
106
- state: 'running' | 'done' | 'error'
107
- /** Bounded first text block of the result, empty until it lands. */
108
- summary: string
109
- /**
110
- * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
111
- * from the tool's persisted presentation metadata; undefined until the
112
- * result lands and only when something renderable exists.
113
- */
114
- detail: ToolDetail | undefined
115
- }
116
-
117
- /** One slash-command execution dispatched through `ctx.commands`. */
118
- export interface CommandEntry {
119
- kind: 'command'
120
- /** Pairing id shared with the matching `command/done`. */
121
- commandId: string
122
- /** Lowercase command name without the leading slash. */
123
- name: string
124
- /** Verbatim text following the command name. */
125
- args: string
126
- /** Execution state; `running` until the paired lifecycle event lands. */
127
- state: 'running' | 'done' | 'error'
128
- /** Handler outcome text, empty until it lands. */
129
- summary: string
130
- }
131
-
132
- /** One turn-level failure surfaced from `turn/end`. */
133
- export interface ErrorEntry {
134
- kind: 'error'
135
- /** `code: message` of the failure. */
136
- text: string
137
- }
138
-
139
- /** One non-error turn outcome surfaced from `turn/end`. */
140
- export interface TurnMarkerEntry {
141
- kind: 'turn-marker'
142
- /** Human-readable outcome line, dim-rendered. */
143
- text: string
144
- }
145
-
146
- /** One completed compaction lifecycle surfaced from `compaction/end`. */
147
- export interface CompactionEntry {
148
- kind: 'compaction'
149
- /** True when the compaction completed, false when it failed. */
150
- ok: boolean
151
- /** Heuristic tokens shadowed by the compaction (summary or prune price). */
152
- tokens: number
153
- /** Failure text when `ok` is false, empty otherwise. */
154
- error: string
155
- }
156
-
157
- /** One provider-routed model-request retry (the `llm/retry` pair). */
158
- export interface RetryEntry {
159
- kind: 'retry'
160
- /** Correlation id shared with the matching `llm/retry-started`. */
161
- retryId: string
162
- /** Retry policy mode from the event: `always` has no attempt cap. */
163
- mode: 'normal' | 'always'
164
- /** Attempt ordinal and its cap. */
165
- attempt: number
166
- max: number
167
- /** Failure code that triggered the retry. */
168
- code: string
169
- /** Backoff wait before the next attempt, in ms. */
170
- delayMs: number
171
- /**
172
- * `running` while the backoff waits, `done` once the attempt started — or
173
- * when the turn ended first (the turn-end sweep finalizes orphans so they
174
- * never pin the settled boundary).
175
- */
176
- state: 'running' | 'done'
177
- }
178
-
179
- /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
180
- export interface FilesEntry {
181
- kind: 'files'
182
- /** Unique mutated paths in call order, bounded. */
183
- paths: readonly string[]
184
- }
185
-
186
- /** Ordered transcript items the renderer draws. */
187
- export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
188
-
189
- /** The live goal the status line badges, folded from `goal/change`. */
190
- export interface GoalFold {
191
- /** Human-requested completion objective. */
192
- objective: string
193
- /** Durable lifecycle phase. */
194
- phase: 'active' | 'paused' | 'blocked' | 'complete'
195
- /** Highest admitted continuation round and its cap. */
196
- rounds: number
197
- max: number
198
- /** Blocked explanation, empty outside the blocked phase. */
199
- blocked: string
200
- }
201
-
202
- /** Cumulative token accounting folded from `assistant/message` usage reports. */
203
- export interface UsageTotals {
204
- /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
205
- inputTokens: number
206
- /** Completion-side tokens over the whole log. */
207
- outputTokens: number
208
- /** Cache-read tokens over the whole log (0 when the adapter reports none). */
209
- cacheReadTokens: number
210
- }
211
-
212
- /**
213
- * Estimated used tokens per context content type, folded from transcript
214
- * events via {@link estimateTokens}. The segmented context bar's composition
215
- * source: proportions across types are meaningful, absolute values are not
216
- * (they never touch billing or the reported `lastPromptTokens`).
217
- */
218
- export interface ContextSegments {
219
- /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
220
- system: number
221
- /** Direct human prompts (durable `user/message` rows). */
222
- prompt: number
223
- /** Assistant text blocks (visible replies). */
224
- assistant: number
225
- /** Assistant reasoning blocks (hidden thinking). */
226
- thinking: number
227
- /** Tool call arguments plus result text. */
228
- tools: number
229
- }
230
-
231
- /** Window-scoped figures the status line shows; timing uses event timestamps. */
232
- export interface TranscriptStats {
233
- /** Durable turns opened (`turn/start` events). */
234
- turns: number
235
- /** Model requests made (`step/start` events). */
236
- steps: number
237
- /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
238
- llmMs: number
239
- /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
240
- toolMs: number
241
- /** Cumulative token accounting; input stays 0 until a report lands. */
242
- usage: UsageTotals
243
- /** Prompt-side size of the most recent reported request (context pressure). */
244
- lastPromptTokens: number
245
- /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
246
- contextWindow: number
247
- /** Estimated used tokens per content type (the segmented bar's composition). */
248
- contextSegments: ContextSegments
249
- /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
250
- ttftMs: number
251
- /** Steps that produced a first chunk (the TTFT average's denominator). */
252
- ttftSteps: number
253
- /** Summed decode spans: first chunk → `assistant/message`, in ms. */
254
- decodeMs: number
255
- /** Completion tokens over timed decode spans (the tok/s numerator). */
256
- decodeTokens: number
257
- /**
258
- * Adapter-owned reasoning effort of the latest `request/header` config
259
- * the EFFECTIVE effort the session actually uses (a materialized model
260
- * default is included, exactly as the adapter resolved it). Empty when the
261
- * header carried none (provider-default behavior). The status line appends
262
- * it to the model segment as `provider/model@effort`.
263
- */
264
- reasoningEffort: string
265
- }
266
-
267
- /** The complete TUI transcript view for one session. */
268
- export interface TranscriptView {
269
- /** Settled entries in log order. */
270
- entries: readonly TranscriptEntry[]
271
- /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
272
- streaming: string
273
- /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
274
- streamingReasoning: string
275
- /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
276
- todos: readonly TodoItem[]
277
- /**
278
- * Global tool-call ordinal counter: the number the NEXT `tool/call` lands
279
- * with (1-based). Never reset, so the counter and the badges/error lines
280
- * stay consistent across turns and resumed sessions.
281
- */
282
- toolCallOrdinal: number
283
- /** True while a durable turn is open (`turn/start` … `turn/end`). */
284
- busy: boolean
285
- /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
286
- busySince: number
287
- /** Figures the status line renders. */
288
- stats: TranscriptStats
289
- /**
290
- * The `provider/model` pair of the last `request/header` snapshot — the
291
- * session's own model record, which a resumed TUI prefers over the
292
- * deployment default (mirrors the web host's resume selection order).
293
- * Empty before the session's first request.
294
- */
295
- model: string
296
- /** Plan mode state folded from the last `plan/mode` event. */
297
- plan: boolean
298
- /** Active permission preset folded from the last `permission/preset` event, empty before one. */
299
- permission: string
300
- /** Latest session title folded from the last `session/title` event, empty before one. */
301
- title: string
302
- /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
303
- sandbox: string
304
- /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
305
- goal: GoalFold | undefined
306
- /**
307
- * Ordered live message ids per inbox target, mirrored from
308
- * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
309
- * coordinates later removals resolve against.
310
- */
311
- pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
312
- /**
313
- * Fold-internal timing anchors, never rendered: open step and tool-call
314
- * start timestamps the next `assistant/message` / `tool/result` resolves
315
- * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
316
- * track which step/tool anchors still belong to the open turn so
317
- * `turn/end` (and a superseding `step/start`) can sweep anchors an
318
- * interruption left behind; `turnFiles` keys mutated paths by turn.
319
- */
320
- readonly anchors: {
321
- stepStart: Map<string, number>
322
- toolStart: Map<string, number>
323
- firstChunkAt: Map<string, number>
324
- compactionTokens: Map<string, number>
325
- lastPruneTokens: number
326
- turnFiles: Map<number, Set<string>>
327
- turnSteps: Map<number, string>
328
- turnTools: Map<number, Set<string>>
329
- }
330
- }
331
-
332
- /** Join the text blocks of a content list; non-text blocks contribute nothing. */
333
- function textOf(content: readonly ContentBlock[]): string {
334
- return content.filter(block => block.type === 'text').map(block => block.text).join('')
335
- }
336
-
337
- /**
338
- * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
339
- * already handed to the renderer must never observe a later fold through a
340
- * shared container. The collections are small and turn-bounded, so cloning
341
- * per event is cheap next to the entries copy the reducer already makes.
342
- */
343
- function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
344
- return {
345
- stepStart: new Map(anchors.stepStart),
346
- toolStart: new Map(anchors.toolStart),
347
- firstChunkAt: new Map(anchors.firstChunkAt),
348
- compactionTokens: new Map(anchors.compactionTokens),
349
- lastPruneTokens: anchors.lastPruneTokens,
350
- turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
351
- turnSteps: new Map(anchors.turnSteps),
352
- turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
353
- }
354
- }
355
-
356
- /** Durable image references in their model-visible order. */
357
- function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
358
- return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
359
- }
360
-
361
- /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
362
- export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
363
- if (images === undefined || images.length === 0) return ''
364
- return images.map((image, index) => {
365
- const rawName = image.name?.trim() || `image ${index + 1}`
366
- const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
367
- const original = image.originalDimensions
368
- const dimensions = original === undefined
369
- ? `${image.width}×${image.height}`
370
- : `${image.width}×${image.height} · original ${original.width}×${original.height}`
371
- return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
372
- }).join('\n')
373
- }
374
-
375
- /** Prompt text with its durable image labels, without exposing local paths or bytes. */
376
- export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images'>): string {
377
- const labels = imageLabels(entry.images)
378
- return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
379
- }
380
-
381
- /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
382
- function reasoningOf(content: readonly ContentBlock[]): string {
383
- return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
384
- }
385
-
386
- /**
387
- * Rough token estimate for the segmented context bar (pi-nano-context's ~4
388
- * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
389
- * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
390
- * drive bar PROPORTIONS, never billing, so precision is not required.
391
- * @param text - the text to estimate.
392
- * @returns an integer token estimate, 0 for empty text.
393
- */
394
- function estimateTokens(text: string): number {
395
- let wide = 0
396
- let narrow = 0
397
- for (const cluster of splitGraphemes(text)) {
398
- if (graphemeWidth(cluster) > 1) wide += 1
399
- else narrow += 1
400
- }
401
- return wide + Math.ceil(narrow / 4)
402
- }
403
-
404
- /** A fresh, empty transcript view. */
405
- export function createTranscriptView(): TranscriptView {
406
- return {
407
- entries: [],
408
- streaming: '',
409
- streamingReasoning: '',
410
- todos: [],
411
- toolCallOrdinal: 0,
412
- busy: false,
413
- busySince: 0,
414
- model: '',
415
- plan: false,
416
- permission: '',
417
- title: '',
418
- sandbox: '',
419
- goal: undefined,
420
- pending: { 'next-turn': [], 'next-step': [] },
421
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
422
- anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map() },
423
- }
424
- }
425
-
426
- /** Full prompt text of a queued message (identical to the durable user row it retires into). */
427
- function pendingText(content: readonly ContentBlock[]): string {
428
- return textOf(content)
429
- }
430
-
431
- /**
432
- * Fold one session event into an updated view (copy-on-write).
433
- * @param view - the view before the event.
434
- * @param event - one durable session event from `session/event` or the log.
435
- * @returns the view after the event; the input view is never mutated.
436
- */
437
- export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
438
- // Fold against a private anchors block so the documented contract holds —
439
- // "the input view is never mutated" — even for the in-place anchor sweeps
440
- // below; without this, every handed-out view shared live Maps.
441
- view = { ...view, anchors: cloneViewAnchors(view.anchors) }
442
- switch (event.type) {
443
- case 'user/message': {
444
- // A queued row retires when its durable user message lands (the agent
445
- // claims the inbox and logs the same message identity) — the transient
446
- // steering/queued preview yields to the real transcript entry.
447
- const message = event.data
448
- let entries = view.entries
449
- let pending = view.pending
450
- for (const target of ['next-turn', 'next-step'] as const) {
451
- const index = pending[target].indexOf(message.id)
452
- if (index < 0) continue
453
- pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
454
- entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
455
- }
456
- // Injected context (plugin/model-continuation sources) stays collapsed
457
- // to a bounded notice row, exactly like collapsed transcript context
458
- // elsewhere in the product; only direct human prompts render in full.
459
- const text = textOf(message.content)
460
- const images = imagesOf(message.content)
461
- if (message.source.kind === 'user') {
462
- return {
463
- ...view,
464
- pending,
465
- entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) }],
466
- stats: {
467
- ...view.stats,
468
- contextSegments: {
469
- ...view.stats.contextSegments,
470
- prompt: view.stats.contextSegments.prompt + estimateTokens(text),
471
- },
472
- },
473
- }
474
- }
475
- const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
476
- ? message.source.summary
477
- : message.source.kind
478
- const summary = boundContextSummary(notice)
479
- return {
480
- ...view,
481
- pending,
482
- entries: [...entries, { kind: 'user', text: summary, notice: true }],
483
- stats: {
484
- ...view.stats,
485
- contextSegments: {
486
- ...view.stats.contextSegments,
487
- system: view.stats.contextSegments.system + estimateTokens(summary),
488
- },
489
- },
490
- }
491
- }
492
- case 'agent/inbox/spliced': {
493
- // The durable inbox mutation (web queue-mirror contract, event-sourced):
494
- // removals drop the projected rows at their inbox coordinates, inserted
495
- // messages gain a pending row at their log position.
496
- const { target, start, removedCount = 0, inserted } = event.data
497
- const ids = view.pending[target]
498
- const removed = ids.slice(start, start + removedCount)
499
- // In-place upstream semantics: the kernel's authoritative fold is
500
- // `inbox.splice(start, removedCount, ...inserted)` inserted ids land
501
- // AT the splice position (prepend/replace shapes), never at the tail.
502
- // A tail append diverged the id order, so later coordinate-based events
503
- // (next-turn head claims, positioned remove/replace) tombstoned the
504
- // wrong pending row.
505
- const nextIds = [
506
- ...ids.slice(0, start),
507
- ...inserted.map(message => message.id),
508
- ...ids.slice(start + removedCount),
509
- ]
510
- let entries = view.entries
511
- if (removed.length > 0) {
512
- const removedSet = new Set(removed)
513
- entries = entries.filter(entry =>
514
- !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
515
- }
516
- for (const message of inserted) {
517
- entries = [...entries, {
518
- kind: 'pending',
519
- messageId: message.id,
520
- target,
521
- text: pendingText(message.content),
522
- ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
523
- }]
524
- }
525
- return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
526
- }
527
- case 'assistant/chunk': {
528
- const chunk = event.data.chunk
529
- // First-token latency: the first non-empty delta of a step anchors the
530
- // TTFT (empty keep-alive deltas do not count as tokens).
531
- const key = `${event.data.turn}:${event.data.step}`
532
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
533
- let stats = view.stats
534
- if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
535
- view.anchors.firstChunkAt.set(key, event.time)
536
- const started = view.anchors.stepStart.get(key)
537
- if (started !== undefined) {
538
- stats = {
539
- ...stats,
540
- ttftMs: stats.ttftMs + Math.max(0, event.time - started),
541
- ttftSteps: stats.ttftSteps + 1,
542
- }
543
- }
544
- }
545
- if (chunk.type === 'text-delta') {
546
- return {
547
- ...view,
548
- streaming: appendStreamingTail(view.streaming, chunk.text),
549
- stats,
550
- }
551
- }
552
- if (chunk.type === 'reasoning-delta') {
553
- return {
554
- ...view,
555
- streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text),
556
- stats,
557
- }
558
- }
559
- return view
560
- }
561
- case 'assistant/message': {
562
- // The assembled message is authoritative; drop the streamed buffers.
563
- const key = `${event.data.turn}:${event.data.step}`
564
- const started = view.anchors.stepStart.get(key)
565
- view.anchors.stepStart.delete(key)
566
- const firstChunk = view.anchors.firstChunkAt.get(key)
567
- view.anchors.firstChunkAt.delete(key)
568
- // The assembled message consumes the turn's current step anchor; a
569
- // later `turn/end` sweep then has nothing left to clean for this step.
570
- if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
571
- const usage = event.data.usage
572
- const totals = view.stats.usage
573
- const text = textOf(event.data.message.content)
574
- const reasoning = reasoningOf(event.data.message.content)
575
- return {
576
- ...view,
577
- streaming: '',
578
- streamingReasoning: '',
579
- entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
580
- stats: {
581
- ...view.stats,
582
- llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
583
- usage: usage === undefined ? totals : {
584
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
585
- outputTokens: totals.outputTokens + usage.outputTokens,
586
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
587
- },
588
- lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
589
- : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
590
- // Decode span and its tokens pair up: an un-timed step (no first
591
- // chunk landed) contributes neither, so the rate stays honest.
592
- decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
593
- decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
594
- contextSegments: {
595
- ...view.stats.contextSegments,
596
- thinking: view.stats.contextSegments.thinking + estimateTokens(reasoning),
597
- assistant: view.stats.contextSegments.assistant + estimateTokens(text),
598
- },
599
- },
600
- }
601
- }
602
- case 'tool/call': {
603
- const data = event.data
604
- view.anchors.toolStart.set(data.callId, event.time)
605
- // Remember the call's turn so `turn/end` can sweep a start that never
606
- // pairs with a result (an interrupted tool otherwise leaks its anchor).
607
- const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
608
- turnTools.add(data.callId)
609
- view.anchors.turnTools.set(data.turn, turnTools)
610
- const ordinal = view.toolCallOrdinal + 1
611
- return {
612
- ...view,
613
- toolCallOrdinal: ordinal,
614
- entries: [
615
- ...view.entries,
616
- {
617
- kind: 'tool',
618
- callId: data.callId,
619
- ordinal,
620
- name: data.name,
621
- arguments: data.arguments,
622
- preview: toolArgumentsPreview(data.arguments, data.name),
623
- prompt: toolPromptPreview(data.name, data.arguments),
624
- state: 'running',
625
- summary: '',
626
- detail: undefined,
627
- }],
628
- stats: {
629
- ...view.stats,
630
- contextSegments: {
631
- ...view.stats.contextSegments,
632
- tools: view.stats.contextSegments.tools
633
- + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
634
- },
635
- },
636
- }
637
- }
638
- case 'tool/result': {
639
- const block = event.data.message.content[0]
640
- const started = view.anchors.toolStart.get(block.toolCallId)
641
- view.anchors.toolStart.delete(block.toolCallId)
642
- // Deregister the call from its turn's registry so `turn/end` does not
643
- // sweep a start that already paired with a result.
644
- const turnTools = view.anchors.turnTools.get(event.data.turn)
645
- if (turnTools !== undefined) {
646
- turnTools.delete(block.toolCallId)
647
- if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
648
- }
649
- const rawText = textOf(block.content)
650
- const summary = boundContextSummary(rawText)
651
- // The verbose expansion self-serves from the persisted presentation
652
- // metadata (diffs, read windows, web sources) with the bounded raw text
653
- // as the universal fallback — the capable-UI degradation ladder.
654
- const detail = toolResultDetail(event.data.meta, rawText)
655
- // Turn-tail deliverables: a diff-bearing mutation records its paths.
656
- if (detail?.kind === 'diff') {
657
- const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
658
- for (const diff of detail.diffs) set.add(diff.path)
659
- view.anchors.turnFiles.set(event.data.turn, set)
660
- }
661
- const entries = view.entries.map((entry) => {
662
- if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
663
- return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
664
- })
665
- return {
666
- ...view,
667
- entries,
668
- stats: {
669
- ...view.stats,
670
- toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
671
- contextSegments: {
672
- ...view.stats.contextSegments,
673
- tools: view.stats.contextSegments.tools + estimateTokens(rawText),
674
- },
675
- },
676
- }
677
- }
678
- case 'todo/write':
679
- return { ...view, todos: event.data.todos }
680
- case 'turn/start':
681
- // The web todo projection clears on turn/start: a fresh turn's first
682
- // write is the authoritative list, and a stale snapshot must not linger
683
- // through a turn that has not written one yet.
684
- return {
685
- ...view,
686
- busy: true,
687
- busySince: view.busy ? view.busySince : event.time,
688
- todos: [],
689
- stats: { ...view.stats, turns: view.stats.turns + 1 },
690
- }
691
- case 'step/start': {
692
- // A step supersedes the turn's previous step: if that step never
693
- // assembled a message (interrupted), its timing anchors are stale the
694
- // moment the next step opens and are swept here instead of leaking.
695
- const key = `${event.data.turn}:${event.data.step}`
696
- const previous = view.anchors.turnSteps.get(event.data.turn)
697
- if (previous !== undefined && previous !== key) {
698
- view.anchors.stepStart.delete(previous)
699
- view.anchors.firstChunkAt.delete(previous)
700
- }
701
- view.anchors.turnSteps.set(event.data.turn, key)
702
- view.anchors.stepStart.set(key, event.time)
703
- return {
704
- ...view,
705
- streaming: '',
706
- streamingReasoning: '',
707
- stats: { ...view.stats, steps: view.stats.steps + 1 },
708
- }
709
- }
710
- case 'turn/end': {
711
- const reason = event.data.reason
712
- const appended: TranscriptEntry[] = []
713
- if (reason.kind === 'error') {
714
- const recovery = reason.error.code === 'MISSING_CREDENTIAL'
715
- ? ' · open /model to add an API key'
716
- : ''
717
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
718
- } else {
719
- // Non-error outcomes deserve their own durable row (the web renders
720
- // distinct max-tokens / abort / interruption nodes); `completed` stays
721
- // silent so an ordinary turn never grows a marker.
722
- const marker = reason.kind === 'aborted'
723
- ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
724
- : reason.kind === 'max-tokens'
725
- ? 'turn hit the output-token ceiling (max-tokens)'
726
- : reason.kind === 'blocked'
727
- ? 'turn ended blocked'
728
- : reason.kind === 'interrupted'
729
- ? 'turn was interrupted by a restart'
730
- : undefined
731
- if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
732
- }
733
- // Deliverables ride the turn tail (the web's turnTail chips): the
734
- // turn's mutated files flush as one bounded row, then the set resets.
735
- const files = view.anchors.turnFiles.get(event.data.turn)
736
- view.anchors.turnFiles.delete(event.data.turn)
737
- if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
738
- // Derivable boundary sweep: the turn is over, so any step/tool anchors
739
- // it left behind (interruptions that never produced their message or
740
- // result) can never be resolved and are reclaimed now.
741
- const stepKey = view.anchors.turnSteps.get(event.data.turn)
742
- if (stepKey !== undefined) {
743
- view.anchors.stepStart.delete(stepKey)
744
- view.anchors.firstChunkAt.delete(stepKey)
745
- view.anchors.turnSteps.delete(event.data.turn)
746
- }
747
- const turnToolSet = view.anchors.turnTools.get(event.data.turn)
748
- if (turnToolSet !== undefined) {
749
- for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
750
- view.anchors.turnTools.delete(event.data.turn)
751
- }
752
- // Orphaned retry/command rows can never be resolved after the turn
753
- // ends: an aborted retry backoff returns upstream without its
754
- // `llm/retry-started`, and crash repair synthesizes only tool/step/
755
- // turn closers. Left `running` they pin the settled boundary forever,
756
- // so the turn end finalizes them exactly like the anchor sweep above.
757
- let orphans = false
758
- const swept = view.entries.map((entry) => {
759
- if (entry.kind === 'retry' && entry.state === 'running') {
760
- orphans = true
761
- return { ...entry, state: 'done' as const }
762
- }
763
- if (entry.kind === 'command' && entry.state === 'running') {
764
- orphans = true
765
- return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
766
- }
767
- return entry
768
- })
769
- const entries = orphans ? swept : view.entries
770
- if (appended.length === 0) {
771
- return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
772
- }
773
- return {
774
- ...view,
775
- busy: false,
776
- busySince: 0,
777
- streaming: '',
778
- streamingReasoning: '',
779
- entries: [...entries, ...appended],
780
- }
781
- }
782
- case 'llm/retry': {
783
- const data = event.data
784
- return {
785
- ...view,
786
- streaming: '',
787
- streamingReasoning: '',
788
- entries: [...view.entries, {
789
- kind: 'retry',
790
- retryId: data.retryId,
791
- mode: data.mode,
792
- attempt: data.retry,
793
- max: 'maxRetries' in data ? data.maxRetries : data.retry,
794
- code: data.failure.code,
795
- delayMs: data.delayMs,
796
- state: 'running',
797
- }],
798
- }
799
- }
800
- case 'llm/retry-started': {
801
- const data = event.data
802
- const entries = view.entries.map((entry) => {
803
- if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
804
- return { ...entry, state: 'done' as const }
805
- })
806
- return { ...view, entries }
807
- }
808
- case 'sandbox/mode':
809
- // Log-only override switch; last write wins for the status badge.
810
- return { ...view, sandbox: event.data.mode }
811
- case 'goal/change': {
812
- const data = event.data
813
- const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
814
- if (data.operation === 'clear') {
815
- return {
816
- ...view,
817
- goal: undefined,
818
- entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
819
- }
820
- }
821
- const goal: GoalFold = {
822
- objective: data.goal.objective,
823
- phase: data.goal.phase,
824
- rounds: data.roundsStarted,
825
- max: data.goal.maxGoalRounds,
826
- blocked: data.goal.blockedReason?.message ?? '',
827
- }
828
- const line = data.operation === 'create'
829
- ? `◎ goal: ${clip(data.goal.objective)}`
830
- : data.operation === 'complete'
831
- ? ' goal complete'
832
- : data.operation === 'pause'
833
- ? '◎ goal paused'
834
- : data.operation === 'resume'
835
- ? '◎ goal resumed'
836
- : data.operation === 'block'
837
- ? `◎ goal blocked: ${clip(goal.blocked)}`
838
- : undefined
839
- return {
840
- ...view,
841
- goal,
842
- entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
843
- }
844
- }
845
- case 'session/title':
846
- // Latest-wins title snapshot, log-only; the status line prefers it.
847
- return { ...view, title: event.data.title }
848
- case 'compaction/summary':
849
- // Remember the shadow price so the matching `compaction/end` row can
850
- // state what the compaction reclaimed. The map is capped so an aborted
851
- // compaction (summary without end) cannot leave an unbounded residue.
852
- if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
853
- const oldest = view.anchors.compactionTokens.keys().next().value
854
- if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
855
- }
856
- view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
857
- return view
858
- case 'compaction/prune':
859
- // A model-free prune carries no compaction id; its price serves the next
860
- // `compaction/end` that cannot find a summary price.
861
- return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
862
- case 'compaction/end': {
863
- const ok = event.data.error === undefined
864
- const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
865
- view.anchors.compactionTokens.delete(event.data.compactionId)
866
- return {
867
- ...view,
868
- entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
869
- }
870
- }
871
- case 'request/context':
872
- // Route capacity, logged only when it changes; last one wins.
873
- return {
874
- ...view,
875
- stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
876
- }
877
- case 'request/header': {
878
- // The session's own model record: the latest snapshot's provider/model
879
- // pair, exactly what a resumed TUI restores as the selection, plus the
880
- // effective reasoning effort that snapshot carried (the adapter may
881
- // materialize the model default, which is what the status line shows).
882
- // The snapshot's rendered system prompt is the current system slot, so
883
- // it REPLACES the estimate (an older system prompt is not re-sent).
884
- const config = event.data.header.config
885
- return {
886
- ...view,
887
- model: `${config.provider}/${config.model}`,
888
- stats: {
889
- ...view.stats,
890
- reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
891
- contextSegments: {
892
- ...view.stats.contextSegments,
893
- system: estimateTokens(event.data.header.system ?? ''),
894
- },
895
- },
896
- }
897
- }
898
- case 'plan/mode':
899
- // Whole-value replace; the last one wins (upstream fold semantics).
900
- return { ...view, plan: event.data.active }
901
- case 'permission/preset':
902
- return { ...view, permission: event.data.preset }
903
- case 'command/run': {
904
- const data = event.data
905
- return {
906
- ...view,
907
- entries: [...view.entries, {
908
- kind: 'command',
909
- commandId: data.commandId,
910
- name: data.name,
911
- args: data.args ?? '',
912
- state: 'running',
913
- summary: '',
914
- }],
915
- }
916
- }
917
- case 'command/done': {
918
- const data = event.data
919
- const entries = view.entries.map((entry) => {
920
- if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
921
- return {
922
- ...entry,
923
- state: data.kind === 'success' ? 'done' as const : 'error' as const,
924
- summary: boundContextSummary(data.text ?? ''),
925
- }
926
- })
927
- return { ...view, entries }
928
- }
929
- default:
930
- return view
931
- }
932
- }
933
-
934
- /**
935
- * Mutable replay accumulator: folds a persisted log into the identical view
936
- * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
937
- * is copy-on-write — every append/scan rebuilds the whole `entries` array, so
938
- * folding a full log costs O(N²) — the accumulator appends by push, resolves
939
- * id-keyed updates (tool/result, command/done, retry-started) through index
940
- * maps, and tombstones retired pending rows, so the whole log folds in O(N)
941
- * plus one compaction pass when tombstones exist.
942
- *
943
- * Index maps never delete: every appended row registers its index, so an id
944
- * lookup miss provably means no matching row exists and the update is an O(1)
945
- * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
946
- * scans). Each id maps to ALL of its indices, so a duplicate id updates every
947
- * matching row exactly like the copy-on-write reducer.
948
- *
949
- * @internal Exported only so tests can (a) prove replay ≡ sequential
950
- * `projectEvent` folds and (b) assert the linear complexity deterministically
951
- * via {@link ReplayAccumulator.ops}, which counts entry-level container work
952
- * instead of relying on wall-clock thresholds. No public consumer.
953
- */
954
- export interface ReplayAccumulator {
955
- /** Working entry list; `undefined` marks a retired pending row (tombstone). */
956
- entries: (TranscriptEntry | undefined)[]
957
- /** callId every index into `entries` holding a `tool` row with that id. */
958
- toolIndex: Map<string, number[]>
959
- /** commandId → every index into `entries` holding a `command` row with that id. */
960
- commandIndex: Map<string, number[]>
961
- /** retryId → every index into `entries` holding a `retry` row with that id. */
962
- retryIndex: Map<string, number[]>
963
- /** messageId every index into `entries` holding a `pending` row with that id. */
964
- pendingIndex: Map<string, number[]>
965
- /** Tombstone count; zero means `entries` is already the final array. */
966
- removedCount: number
967
- /** Mutable inbox id lists, mirroring `view.pending` order per target. */
968
- pendingTurn: string[]
969
- pendingStep: string[]
970
- streaming: string
971
- streamingReasoning: string
972
- todos: readonly TodoItem[]
973
- /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
974
- toolCallOrdinal: number
975
- busy: boolean
976
- busySince: number
977
- model: string
978
- plan: boolean
979
- permission: string
980
- title: string
981
- sandbox: string
982
- goal: GoalFold | undefined
983
- stats: TranscriptStats
984
- stepStart: Map<string, number>
985
- toolStart: Map<string, number>
986
- firstChunkAt: Map<string, number>
987
- compactionTokens: Map<string, number>
988
- lastPruneTokens: number
989
- turnFiles: Map<number, Set<string>>
990
- turnSteps: Map<number, string>
991
- turnTools: Map<number, Set<string>>
992
- /** Entry-level container operations performed so far (test instrumentation). */
993
- ops: number
994
- }
995
-
996
- /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
997
- export function createReplayAccumulator(): ReplayAccumulator {
998
- return {
999
- entries: [],
1000
- toolIndex: new Map(),
1001
- commandIndex: new Map(),
1002
- retryIndex: new Map(),
1003
- pendingIndex: new Map(),
1004
- removedCount: 0,
1005
- pendingTurn: [],
1006
- pendingStep: [],
1007
- streaming: '',
1008
- streamingReasoning: '',
1009
- todos: [],
1010
- toolCallOrdinal: 0,
1011
- busy: false,
1012
- busySince: 0,
1013
- model: '',
1014
- plan: false,
1015
- permission: '',
1016
- title: '',
1017
- sandbox: '',
1018
- goal: undefined,
1019
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
1020
- stepStart: new Map(),
1021
- toolStart: new Map(),
1022
- firstChunkAt: new Map(),
1023
- compactionTokens: new Map(),
1024
- lastPruneTokens: 0,
1025
- turnFiles: new Map(),
1026
- turnSteps: new Map(),
1027
- turnTools: new Map(),
1028
- ops: 0,
1029
- }
1030
- }
1031
-
1032
- /** Append one entry (O(1)) and account the push. */
1033
- function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
1034
- acc.entries.push(entry)
1035
- acc.ops += 1
1036
- }
1037
-
1038
- /**
1039
- * Get (or create) the index list an id owns. Lists are never removed: every
1040
- * appended row registers its index, so a lookup miss later proves no matching
1041
- * row exists and the caller can no-op in O(1).
1042
- */
1043
- function indexList(map: Map<string, number[]>, id: string): number[] {
1044
- let list = map.get(id)
1045
- if (list === undefined) {
1046
- list = []
1047
- map.set(id, list)
1048
- }
1049
- return list
1050
- }
1051
-
1052
- /**
1053
- * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1054
- * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1055
- * settles `error` with an interruption note. Only the id-indexed rows are
1056
- * visited, so the sweep stays O(retries+commands of the log), never a scan.
1057
- */
1058
- function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1059
- for (const list of acc.retryIndex.values()) {
1060
- for (const index of list) {
1061
- const entry = acc.entries[index]
1062
- if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1063
- acc.entries[index] = { ...entry, state: 'done' }
1064
- }
1065
- }
1066
- }
1067
- for (const list of acc.commandIndex.values()) {
1068
- for (const index of list) {
1069
- const entry = acc.entries[index]
1070
- if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1071
- acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1072
- }
1073
- }
1074
- }
1075
- }
1076
-
1077
- /**
1078
- * Apply an id-keyed update to every row that registered the id, mirroring the
1079
- * copy-on-write reducer's full-array map semantics (all matching rows update,
1080
- * in order). Each registered index is O(1), so a duplicate id costs
1081
- * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
1082
- * registered indices are valid by construction, because tool/command/retry
1083
- * rows are never removed and tombstones never shift indices.
1084
- */
1085
- function updateReplayById<T extends TranscriptEntry>(
1086
- acc: ReplayAccumulator,
1087
- map: Map<string, number[]>,
1088
- id: string,
1089
- isMatch: (entry: T) => boolean,
1090
- update: (entry: T) => T,
1091
- ): void {
1092
- const list = map.get(id)
1093
- if (list === undefined) return // miss provably means no matching row
1094
- for (const index of list) {
1095
- const entry = acc.entries[index]
1096
- if (entry === undefined || !isMatch(entry as T)) continue
1097
- acc.entries[index] = update(entry as T)
1098
- acc.ops += 1
1099
- }
1100
- }
1101
-
1102
- /** Tombstone a retired pending row, keeping every other index stable. */
1103
- function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1104
- if (acc.entries[index] !== undefined) {
1105
- acc.entries[index] = undefined
1106
- acc.removedCount += 1
1107
- acc.ops += 1
1108
- }
1109
- }
1110
-
1111
- /**
1112
- * Fold one session event into a replay accumulator. This mirrors
1113
- * {@link projectEvent} case for case — same stats arithmetic, same anchor
1114
- * set/delete behavior, same entry shapes — so the finished view is identical
1115
- * to a sequential fold; only the `entries` container operations are mutable.
1116
- *
1117
- * @internal Test-instrumentation path; `projectEvents` is the public entry.
1118
- * @returns whether the event changed the accumulated state — the live store
1119
- * stays silent and keeps its snapshot identity for ignored events, exactly
1120
- * like the copy-on-write reducer returning its input view unchanged.
1121
- */
1122
- export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1123
- switch (event.type) {
1124
- case 'user/message': {
1125
- const message = event.data
1126
- for (const target of ['next-turn', 'next-step'] as const) {
1127
- const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1128
- const index = ids.indexOf(message.id)
1129
- acc.ops += index < 0 ? ids.length : index + 1
1130
- if (index < 0) continue
1131
- ids.splice(index, 1)
1132
- acc.ops += 1
1133
- // Retire every pending row carrying this message id (duplicate ids
1134
- // included), exactly like the reducer's full-array filter.
1135
- const list = acc.pendingIndex.get(message.id)
1136
- if (list !== undefined) {
1137
- for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
1138
- acc.ops += 1
1139
- }
1140
- }
1141
- const text = textOf(message.content)
1142
- const images = imagesOf(message.content)
1143
- if (message.source.kind === 'user') {
1144
- appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }) })
1145
- acc.stats = {
1146
- ...acc.stats,
1147
- contextSegments: {
1148
- ...acc.stats.contextSegments,
1149
- prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1150
- },
1151
- }
1152
- return true
1153
- }
1154
- const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1155
- ? message.source.summary
1156
- : message.source.kind
1157
- const summary = boundContextSummary(notice)
1158
- appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1159
- acc.stats = {
1160
- ...acc.stats,
1161
- contextSegments: {
1162
- ...acc.stats.contextSegments,
1163
- system: acc.stats.contextSegments.system + estimateTokens(summary),
1164
- },
1165
- }
1166
- return true
1167
- }
1168
- case 'agent/inbox/spliced': {
1169
- const { target, start, removedCount = 0, inserted } = event.data
1170
- const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1171
- const removed = ids.slice(start, start + removedCount)
1172
- acc.ops += removed.length
1173
- ids.splice(start, removedCount)
1174
- acc.ops += removed.length
1175
- for (const id of removed) {
1176
- const list = acc.pendingIndex.get(id)
1177
- if (list === undefined) continue
1178
- for (const entryIndex of list) {
1179
- const entry = acc.entries[entryIndex]
1180
- if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1181
- retireReplayEntry(acc, entryIndex)
1182
- }
1183
- }
1184
- }
1185
- // Mirror the reducer's in-place order: inserted ids join at the splice
1186
- // position (upstream `splice(start, removedCount, ...inserted)`), never
1187
- // at the tail — the id list must stay coordinate-compatible with every
1188
- // later inbox event.
1189
- ids.splice(start, 0, ...inserted.map(message => message.id))
1190
- for (const message of inserted) {
1191
- const images = imagesOf(message.content)
1192
- appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1193
- indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1194
- acc.ops += 1
1195
- }
1196
- return true
1197
- }
1198
- case 'assistant/chunk': {
1199
- const chunk = event.data.chunk
1200
- const key = `${event.data.turn}:${event.data.step}`
1201
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
1202
- if (delta !== '' && !acc.firstChunkAt.has(key)) {
1203
- acc.firstChunkAt.set(key, event.time)
1204
- const started = acc.stepStart.get(key)
1205
- if (started !== undefined) {
1206
- acc.stats = {
1207
- ...acc.stats,
1208
- ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
1209
- ttftSteps: acc.stats.ttftSteps + 1,
1210
- }
1211
- }
1212
- }
1213
- if (chunk.type === 'text-delta') {
1214
- acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1215
- return true
1216
- }
1217
- if (chunk.type === 'reasoning-delta') {
1218
- acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1219
- return true
1220
- }
1221
- return false
1222
- }
1223
- case 'assistant/message': {
1224
- const key = `${event.data.turn}:${event.data.step}`
1225
- const started = acc.stepStart.get(key)
1226
- acc.stepStart.delete(key)
1227
- const firstChunk = acc.firstChunkAt.get(key)
1228
- acc.firstChunkAt.delete(key)
1229
- if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1230
- const usage = event.data.usage
1231
- const totals = acc.stats.usage
1232
- const text = textOf(event.data.message.content)
1233
- const reasoning = reasoningOf(event.data.message.content)
1234
- acc.streaming = ''
1235
- acc.streamingReasoning = ''
1236
- appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1237
- acc.stats = {
1238
- ...acc.stats,
1239
- llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1240
- usage: usage === undefined ? totals : {
1241
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1242
- outputTokens: totals.outputTokens + usage.outputTokens,
1243
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1244
- },
1245
- lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1246
- : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1247
- decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1248
- decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1249
- contextSegments: {
1250
- ...acc.stats.contextSegments,
1251
- thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1252
- assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1253
- },
1254
- }
1255
- return true
1256
- }
1257
- case 'tool/call': {
1258
- const data = event.data
1259
- acc.toolStart.set(data.callId, event.time)
1260
- const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1261
- turnTools.add(data.callId)
1262
- acc.turnTools.set(data.turn, turnTools)
1263
- acc.toolCallOrdinal += 1
1264
- appendReplayEntry(acc, {
1265
- kind: 'tool',
1266
- callId: data.callId,
1267
- ordinal: acc.toolCallOrdinal,
1268
- name: data.name,
1269
- arguments: data.arguments,
1270
- preview: toolArgumentsPreview(data.arguments, data.name),
1271
- prompt: toolPromptPreview(data.name, data.arguments),
1272
- state: 'running',
1273
- summary: '',
1274
- detail: undefined,
1275
- })
1276
- indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1277
- acc.stats = {
1278
- ...acc.stats,
1279
- contextSegments: {
1280
- ...acc.stats.contextSegments,
1281
- tools: acc.stats.contextSegments.tools
1282
- + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1283
- },
1284
- }
1285
- return true
1286
- }
1287
- case 'tool/result': {
1288
- const block = event.data.message.content[0]
1289
- const started = acc.toolStart.get(block.toolCallId)
1290
- acc.toolStart.delete(block.toolCallId)
1291
- const turnTools = acc.turnTools.get(event.data.turn)
1292
- if (turnTools !== undefined) {
1293
- turnTools.delete(block.toolCallId)
1294
- if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1295
- }
1296
- const rawText = textOf(block.content)
1297
- const summary = boundContextSummary(rawText)
1298
- const detail = toolResultDetail(event.data.meta, rawText)
1299
- if (detail?.kind === 'diff') {
1300
- const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1301
- for (const diff of detail.diffs) set.add(diff.path)
1302
- acc.turnFiles.set(event.data.turn, set)
1303
- }
1304
- const update = (entry: ToolEntry): ToolEntry => ({
1305
- ...entry,
1306
- state: block.isError === true ? 'error' as const : 'done' as const,
1307
- summary,
1308
- detail,
1309
- })
1310
- // Every matching row updates (duplicate callIds included); an id with no
1311
- // registered index is a provable no-op — no full-array fallback scan.
1312
- updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1313
- acc.stats = {
1314
- ...acc.stats,
1315
- toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1316
- contextSegments: {
1317
- ...acc.stats.contextSegments,
1318
- tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1319
- },
1320
- }
1321
- return true
1322
- }
1323
- case 'todo/write':
1324
- acc.todos = event.data.todos
1325
- return true
1326
- case 'turn/start': {
1327
- const wasBusy = acc.busy
1328
- acc.busy = true
1329
- acc.busySince = wasBusy ? acc.busySince : event.time
1330
- acc.todos = []
1331
- acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1332
- return true
1333
- }
1334
- case 'step/start': {
1335
- const key = `${event.data.turn}:${event.data.step}`
1336
- const previous = acc.turnSteps.get(event.data.turn)
1337
- if (previous !== undefined && previous !== key) {
1338
- acc.stepStart.delete(previous)
1339
- acc.firstChunkAt.delete(previous)
1340
- }
1341
- acc.turnSteps.set(event.data.turn, key)
1342
- acc.stepStart.set(key, event.time)
1343
- acc.streaming = ''
1344
- acc.streamingReasoning = ''
1345
- acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1346
- return true
1347
- }
1348
- case 'turn/end': {
1349
- const reason = event.data.reason
1350
- const appended: TranscriptEntry[] = []
1351
- acc.streamingReasoning = ''
1352
- acc.streaming = ''
1353
- if (reason.kind === 'error') {
1354
- const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1355
- ? ' · open /model to add an API key'
1356
- : ''
1357
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1358
- } else {
1359
- const marker = reason.kind === 'aborted'
1360
- ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1361
- : reason.kind === 'max-tokens'
1362
- ? 'turn hit the output-token ceiling (max-tokens)'
1363
- : reason.kind === 'blocked'
1364
- ? 'turn ended blocked'
1365
- : reason.kind === 'interrupted'
1366
- ? 'turn was interrupted by a restart'
1367
- : undefined
1368
- if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1369
- }
1370
- const files = acc.turnFiles.get(event.data.turn)
1371
- acc.turnFiles.delete(event.data.turn)
1372
- if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1373
- const stepKey = acc.turnSteps.get(event.data.turn)
1374
- if (stepKey !== undefined) {
1375
- acc.stepStart.delete(stepKey)
1376
- acc.firstChunkAt.delete(stepKey)
1377
- acc.turnSteps.delete(event.data.turn)
1378
- }
1379
- const turnToolSet = acc.turnTools.get(event.data.turn)
1380
- if (turnToolSet !== undefined) {
1381
- for (const callId of turnToolSet) acc.toolStart.delete(callId)
1382
- acc.turnTools.delete(event.data.turn)
1383
- }
1384
- finalizeReplayOrphans(acc)
1385
- acc.busy = false
1386
- acc.busySince = 0
1387
- for (const entry of appended) appendReplayEntry(acc, entry)
1388
- return true
1389
- }
1390
- case 'llm/retry': {
1391
- const data = event.data
1392
- acc.streaming = ''
1393
- acc.streamingReasoning = ''
1394
- appendReplayEntry(acc, {
1395
- kind: 'retry',
1396
- retryId: data.retryId,
1397
- mode: data.mode,
1398
- attempt: data.retry,
1399
- max: 'maxRetries' in data ? data.maxRetries : data.retry,
1400
- code: data.failure.code,
1401
- delayMs: data.delayMs,
1402
- state: 'running',
1403
- })
1404
- indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1405
- return true
1406
- }
1407
- case 'llm/retry-started': {
1408
- const data = event.data
1409
- updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1410
- return true
1411
- }
1412
- case 'sandbox/mode':
1413
- acc.sandbox = event.data.mode
1414
- return true
1415
- case 'goal/change': {
1416
- const data = event.data
1417
- const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1418
- if (data.operation === 'clear') {
1419
- acc.goal = undefined
1420
- appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1421
- return true
1422
- }
1423
- const goal: GoalFold = {
1424
- objective: data.goal.objective,
1425
- phase: data.goal.phase,
1426
- rounds: data.roundsStarted,
1427
- max: data.goal.maxGoalRounds,
1428
- blocked: data.goal.blockedReason?.message ?? '',
1429
- }
1430
- const line = data.operation === 'create'
1431
- ? `◎ goal: ${clip(data.goal.objective)}`
1432
- : data.operation === 'complete'
1433
- ? '◎ goal complete'
1434
- : data.operation === 'pause'
1435
- ? '◎ goal paused'
1436
- : data.operation === 'resume'
1437
- ? '◎ goal resumed'
1438
- : data.operation === 'block'
1439
- ? `◎ goal blocked: ${clip(goal.blocked)}`
1440
- : undefined
1441
- acc.goal = goal
1442
- if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1443
- return true
1444
- }
1445
- case 'session/title':
1446
- acc.title = event.data.title
1447
- return true
1448
- case 'compaction/summary':
1449
- if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1450
- const oldest = acc.compactionTokens.keys().next().value
1451
- if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1452
- }
1453
- acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1454
- return true
1455
- case 'compaction/prune':
1456
- acc.lastPruneTokens = event.data.shadowedTokenCount
1457
- return true
1458
- case 'compaction/end': {
1459
- const ok = event.data.error === undefined
1460
- const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1461
- acc.compactionTokens.delete(event.data.compactionId)
1462
- appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1463
- return true
1464
- }
1465
- case 'request/context':
1466
- acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1467
- return true
1468
- case 'request/header': {
1469
- const config = event.data.header.config
1470
- acc.model = `${config.provider}/${config.model}`
1471
- acc.stats = {
1472
- ...acc.stats,
1473
- reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1474
- contextSegments: {
1475
- ...acc.stats.contextSegments,
1476
- system: estimateTokens(event.data.header.system ?? ''),
1477
- },
1478
- }
1479
- return true
1480
- }
1481
- case 'plan/mode':
1482
- acc.plan = event.data.active
1483
- return true
1484
- case 'permission/preset':
1485
- acc.permission = event.data.preset
1486
- return true
1487
- case 'command/run': {
1488
- const data = event.data
1489
- appendReplayEntry(acc, {
1490
- kind: 'command',
1491
- commandId: data.commandId,
1492
- name: data.name,
1493
- args: data.args ?? '',
1494
- state: 'running',
1495
- summary: '',
1496
- })
1497
- indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1498
- return true
1499
- }
1500
- case 'command/done': {
1501
- const data = event.data
1502
- const update = (candidate: CommandEntry): CommandEntry => ({
1503
- ...candidate,
1504
- state: data.kind === 'success' ? 'done' as const : 'error' as const,
1505
- summary: boundContextSummary(data.text ?? ''),
1506
- })
1507
- updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1508
- return true
1509
- }
1510
- default:
1511
- return false
1512
- }
1513
- }
1514
-
1515
- /**
1516
- * Materialize the accumulated fold as a `TranscriptView`, compacting any
1517
- * retired tombstones. The anchors maps are handed through as-is (their
1518
- * content is identical to a sequential fold's).
1519
- *
1520
- * @internal Test-instrumentation path; `projectEvents` is the public entry.
1521
- */
1522
- export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1523
- return materializeReplayView(acc, false)
1524
- }
1525
-
1526
- /**
1527
- * Materialize the accumulated fold as a fresh immutable snapshot for the
1528
- * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1529
- * hands the accumulator's own arrays through because the accumulator is
1530
- * discarded — every array a renderer can hold is copied here, so later
1531
- * folds never mutate a snapshot already handed out. Same fields, same
1532
- * tombstone compaction.
1533
- *
1534
- * @internal Live-store path; `projectEvents` is the public entry.
1535
- */
1536
- export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1537
- return materializeReplayView(acc, true)
1538
- }
1539
-
1540
- /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1541
- function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1542
- const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1543
- ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1544
- : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1545
- if (acc.removedCount > 0) acc.ops += acc.entries.length
1546
- return {
1547
- entries,
1548
- streaming: acc.streaming,
1549
- streamingReasoning: acc.streamingReasoning,
1550
- todos: acc.todos,
1551
- toolCallOrdinal: acc.toolCallOrdinal,
1552
- busy: acc.busy,
1553
- busySince: acc.busySince,
1554
- model: acc.model,
1555
- plan: acc.plan,
1556
- permission: acc.permission,
1557
- title: acc.title,
1558
- sandbox: acc.sandbox,
1559
- goal: acc.goal,
1560
- pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1561
- stats: acc.stats,
1562
- // Handed-out views get their own anchors snapshot: the accumulator keeps
1563
- // folding its live containers, and no consumer may observe that.
1564
- anchors: {
1565
- stepStart: new Map(acc.stepStart),
1566
- toolStart: new Map(acc.toolStart),
1567
- firstChunkAt: new Map(acc.firstChunkAt),
1568
- compactionTokens: new Map(acc.compactionTokens),
1569
- lastPruneTokens: acc.lastPruneTokens,
1570
- turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1571
- turnSteps: new Map(acc.turnSteps),
1572
- turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1573
- },
1574
- }
1575
- }
1576
-
1577
- /**
1578
- * Fold a replayed event history into one view.
1579
- *
1580
- * Folding is near-linear in the log size: the mutable replay accumulator
1581
- * appends in place and resolves id-keyed updates through index maps, so a
1582
- * long persisted session replays without the O(N²) copy-on-write rebuilds a
1583
- * naive sequential fold would incur. The result is identical to folding
1584
- * {@link projectEvent} per event in order.
1585
- * @param events - events in `seq` order.
1586
- * @returns the folded view.
1587
- */
1588
- export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
1589
- const acc = createReplayAccumulator()
1590
- for (const event of events) replayProjectEvent(acc, event)
1591
- return finishReplay(acc)
1592
- }
1593
-
1594
- /**
1595
- * The append-only flush boundary for a transcript view: the count of entries
1596
- * no later event can remove. Entries at or beyond this index are mutable and
1597
- * must stay in the live tree.
1598
- *
1599
- * `pending` rows are excluded even though they are not a running tool/retry:
1600
- * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1601
- * `user/message` retirement), and an append-only `<Static>` flush cannot
1602
- * erase a row that vanishes from the view — the retired row would ghost on
1603
- * screen until the next source-backed replay. Running commands join the
1604
- * mutable boundary for the same reason in reverse: `command/done` mutates the
1605
- * row's state/summary, so a flushed row would keep its stale running mark
1606
- * until a resize-triggered replay. Everything else (including a completed
1607
- * tail) is final: later events only APPEND new rows.
1608
- * @param entries - the view's transcript entries in order.
1609
- * @returns the count of entries safe to flush (0 for an empty transcript).
1610
- */
1611
- export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1612
- for (let index = 0; index < entries.length; index++) {
1613
- const entry = entries[index]
1614
- if (entry.kind === 'pending') return index
1615
- if (entry.kind === 'tool' && entry.state === 'running') return index
1616
- if (entry.kind === 'retry' && entry.state === 'running') return index
1617
- if (entry.kind === 'command' && entry.state === 'running') return index
1618
- }
1619
- return entries.length
1620
- }
1
+ /**
2
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
3
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
+ * Rendering never reads the session directly — this module owns the view
5
+ * model, so tests drive it with plain event arrays.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/render/projection
8
+ */
9
+
10
+ import { assistantStreamFirstTokenTime, boundContextSummary, isTokenDelta, type ContentBlock, type FileBlock, type ImageBlock, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
11
+ import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
13
+ import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
14
+ import { graphemeWidth, splitGraphemes } from './width.ts'
15
+ // Type-only imports merge the plugin-owned SessionEventMap variants
16
+ // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
17
+ // plan/mode, permission/preset, sandbox/mode, session/title) into the union
18
+ // this reducer switches on.
19
+ import type {} from '@deepseek-ai/dsh-agent'
20
+ import type {} from '@deepseek-ai/dsh-commands'
21
+ import type {} from '@deepseek-ai/dsh-compaction'
22
+ import type {} from '@deepseek-ai/dsh-goal'
23
+ import type {} from '@deepseek-ai/dsh-llm-retry'
24
+ import type {} from '@deepseek-ai/dsh-plan-mode'
25
+ import type {} from '@deepseek-ai/dsh-permission-presets'
26
+ import type {} from '@deepseek-ai/dsh-sandbox-policy'
27
+ import type {} from '@deepseek-ai/dsh-session-title'
28
+ // The subagent package's durable catalog event joins the union the same way
29
+ // (parent-owned facts; the fold itself lives with the live feed).
30
+ import type {} from '@deepseek-ai/dsh-subagent'
31
+ import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
32
+ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
33
+
34
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
35
+ const MAX_STREAMING_CHARS = 65_536
36
+
37
+ /**
38
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
39
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
40
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
41
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
42
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
43
+ * missing summary.
44
+ */
45
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16
46
+
47
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
48
+ function appendStreamingTail(current: string, delta: string): string {
49
+ const next = current + delta
50
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
51
+ }
52
+
53
+ /** One user prompt line. */
54
+ export interface UserEntry {
55
+ kind: 'user'
56
+ /** Joined text blocks of the user message. */
57
+ text: string
58
+ /** True for collapsed injected context (plugin/continuation notices), which
59
+ * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
60
+ notice: boolean
61
+ /** Durable image references carried by this prompt. */
62
+ images?: readonly ImageBlock['attachment'][]
63
+ /** Durable file references carried by this prompt (0.1.5 file blocks). */
64
+ files?: readonly FileAttachmentRef[]
65
+ }
66
+
67
+ /** One user message waiting in the agent inbox (the web's queued-message row). */
68
+ export interface PendingEntry {
69
+ kind: 'pending'
70
+ /** Stable message identity shared with the durable `user/message` that retires it. */
71
+ messageId: MessageId
72
+ /** Which inbox list holds the message: steering is consumed at the next step boundary. */
73
+ target: 'next-turn' | 'next-step'
74
+ /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
75
+ text: string
76
+ /** Durable image references queued with this prompt. */
77
+ images?: readonly ImageBlock['attachment'][]
78
+ /** Durable file references queued with this prompt (0.1.5 file blocks). */
79
+ files?: readonly FileAttachmentRef[]
80
+ }
81
+
82
+ /** One authoritative assembled assistant reply. */
83
+ export interface AssistantEntry {
84
+ kind: 'assistant'
85
+ /** Joined text blocks of the assistant message. */
86
+ text: string
87
+ /** Joined reasoning blocks from the same assembled message. */
88
+ reasoning: string
89
+ /** True when a cancelled stream's delivered prefix was finalized as this
90
+ * entry (rc.8 `assistant/message.interrupted`) — rendered with a marker. */
91
+ interrupted?: true
92
+ }
93
+
94
+ /** One model-requested tool invocation and its settled state. */
95
+ export interface ToolEntry {
96
+ kind: 'tool'
97
+ /** Correlation id shared with the matching `tool/result`. */
98
+ callId: string
99
+ /**
100
+ * Global tool-call ordinal across the whole transcript (1, 2, 3…, never
101
+ * reset between turns). The tool-card badge and every error line that
102
+ * references the failed call share this number, so "call N" in an error
103
+ * always names the exact card the badge shows.
104
+ */
105
+ ordinal: number
106
+ /** Tool name as the model addressed it. */
107
+ name: string
108
+ /** Raw arguments JSON string exactly as the model produced it. */
109
+ arguments: string
110
+ /** Bounded human-meaningful arguments preview for the tool card. */
111
+ preview: string
112
+ /** Bounded delegation prompt (subagent cards' second row), '' when none. */
113
+ prompt: string
114
+ /** Execution state; `running` until the paired result lands. */
115
+ state: 'running' | 'done' | 'error'
116
+ /** Bounded first text block of the result, empty until it lands. */
117
+ summary: string
118
+ /**
119
+ * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
120
+ * from the tool's persisted presentation metadata; undefined until the
121
+ * result lands and only when something renderable exists.
122
+ */
123
+ detail: ToolDetail | undefined
124
+ }
125
+
126
+ /** One slash-command execution dispatched through `ctx.commands`. */
127
+ export interface CommandEntry {
128
+ kind: 'command'
129
+ /** Pairing id shared with the matching `command/done`. */
130
+ commandId: string
131
+ /** Lowercase command name without the leading slash. */
132
+ name: string
133
+ /** Verbatim text following the command name. */
134
+ args: string
135
+ /** Execution state; `running` until the paired lifecycle event lands. */
136
+ state: 'running' | 'done' | 'error'
137
+ /** Handler outcome text, empty until it lands. */
138
+ summary: string
139
+ }
140
+
141
+ /** One turn-level failure surfaced from `turn/end`. */
142
+ export interface ErrorEntry {
143
+ kind: 'error'
144
+ /** `code: message` of the failure. */
145
+ text: string
146
+ }
147
+
148
+ /** One non-error turn outcome surfaced from `turn/end`. */
149
+ export interface TurnMarkerEntry {
150
+ kind: 'turn-marker'
151
+ /** Human-readable outcome line, dim-rendered. */
152
+ text: string
153
+ }
154
+
155
+ /** One completed compaction lifecycle surfaced from `compaction/end`. */
156
+ export interface CompactionEntry {
157
+ kind: 'compaction'
158
+ /** True when the compaction completed, false when it failed. */
159
+ ok: boolean
160
+ /** Heuristic tokens shadowed by the compaction (summary or prune price). */
161
+ tokens: number
162
+ /** Failure text when `ok` is false, empty otherwise. */
163
+ error: string
164
+ }
165
+
166
+ /** One provider-routed model-request retry (the `llm/retry` pair). */
167
+ export interface RetryEntry {
168
+ kind: 'retry'
169
+ /** Correlation id shared with the matching `llm/retry-started`. */
170
+ retryId: string
171
+ /** Retry policy mode from the event: `always` has no attempt cap. */
172
+ mode: 'normal' | 'always'
173
+ /** Attempt ordinal and its cap. */
174
+ attempt: number
175
+ max: number
176
+ /** Failure code that triggered the retry. */
177
+ code: string
178
+ /** Backoff wait before the next attempt, in ms. */
179
+ delayMs: number
180
+ /**
181
+ * `running` while the backoff waits, `done` once the attempt started — or
182
+ * when the turn ended first (the turn-end sweep finalizes orphans so they
183
+ * never pin the settled boundary).
184
+ */
185
+ state: 'running' | 'done'
186
+ }
187
+
188
+ /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
189
+ export interface FilesEntry {
190
+ kind: 'files'
191
+ /** Unique mutated paths in call order, bounded. */
192
+ paths: readonly string[]
193
+ }
194
+
195
+ /** Ordered transcript items the renderer draws. */
196
+ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
197
+
198
+ /** The live goal the status line badges, folded from `goal/change`. */
199
+ export interface GoalFold {
200
+ /** Human-requested completion objective. */
201
+ objective: string
202
+ /** Durable lifecycle phase. */
203
+ phase: 'active' | 'paused' | 'blocked' | 'complete'
204
+ /** Highest admitted continuation round and its cap. */
205
+ rounds: number
206
+ max: number
207
+ /** Blocked explanation, empty outside the blocked phase. */
208
+ blocked: string
209
+ }
210
+
211
+ /** Cumulative token accounting folded from `assistant/message` usage reports. */
212
+ export interface UsageTotals {
213
+ /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
214
+ inputTokens: number
215
+ /** Completion-side tokens over the whole log. */
216
+ outputTokens: number
217
+ /** Cache-read tokens over the whole log (0 when the adapter reports none). */
218
+ cacheReadTokens: number
219
+ }
220
+
221
+ /**
222
+ * Estimated used tokens per context content type, folded from transcript
223
+ * events via {@link estimateTokens}. The segmented context bar's composition
224
+ * source: proportions across types are meaningful, absolute values are not
225
+ * (they never touch billing or the reported `lastPromptTokens`).
226
+ */
227
+ export interface ContextSegments {
228
+ /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
229
+ system: number
230
+ /** Direct human prompts (durable `user/message` rows). */
231
+ prompt: number
232
+ /** Assistant text blocks (visible replies). */
233
+ assistant: number
234
+ /** Assistant reasoning blocks (hidden thinking). */
235
+ thinking: number
236
+ /** Tool call arguments plus result text. */
237
+ tools: number
238
+ }
239
+
240
+ /** Window-scoped figures the status line shows; timing uses event timestamps. */
241
+ export interface TranscriptStats {
242
+ /** Durable turns opened (`turn/start` events). */
243
+ turns: number
244
+ /** Model requests made (`step/start` events). */
245
+ steps: number
246
+ /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
247
+ llmMs: number
248
+ /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
249
+ toolMs: number
250
+ /** Cumulative token accounting; input stays 0 until a report lands. */
251
+ usage: UsageTotals
252
+ /** Prompt-side size of the most recent reported request (context pressure). */
253
+ lastPromptTokens: number
254
+ /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
255
+ contextWindow: number
256
+ /** Estimated used tokens per content type (the segmented bar's composition). */
257
+ contextSegments: ContextSegments
258
+ /** Summed first-token waits: `step/start` first non-empty chunk, in ms. */
259
+ ttftMs: number
260
+ /** Steps that produced a first chunk (the TTFT average's denominator). */
261
+ ttftSteps: number
262
+ /** Summed decode spans: first chunk `assistant/message`, in ms. */
263
+ decodeMs: number
264
+ /** Completion tokens over timed decode spans (the tok/s numerator). */
265
+ decodeTokens: number
266
+ /**
267
+ * Adapter-owned reasoning effort of the latest `request/header` config
268
+ * the EFFECTIVE effort the session actually uses (a materialized model
269
+ * default is included, exactly as the adapter resolved it). Empty when the
270
+ * header carried none (provider-default behavior). The status line appends
271
+ * it to the model segment as `provider/model@effort`.
272
+ */
273
+ reasoningEffort: string
274
+ }
275
+
276
+ /** The complete TUI transcript view for one session. */
277
+ export interface TranscriptView {
278
+ /** Settled entries in log order. */
279
+ entries: readonly TranscriptEntry[]
280
+ /** Bounded text tail accumulated from live stream frames since the last settlement. */
281
+ streaming: string
282
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
283
+ streamingReasoning: string
284
+ /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
285
+ todos: readonly TodoItem[]
286
+ /**
287
+ * Global tool-call ordinal counter: the number the NEXT `tool/call` lands
288
+ * with (1-based). Never reset, so the counter and the badges/error lines
289
+ * stay consistent across turns and resumed sessions.
290
+ */
291
+ toolCallOrdinal: number
292
+ /** True while a durable turn is open (`turn/start` … `turn/end`). */
293
+ busy: boolean
294
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
295
+ busySince: number
296
+ /** Figures the status line renders. */
297
+ stats: TranscriptStats
298
+ /**
299
+ * The `provider/model` pair of the last `request/header` snapshot — the
300
+ * session's own model record, which a resumed TUI prefers over the
301
+ * deployment default (mirrors the web host's resume selection order).
302
+ * Empty before the session's first request.
303
+ */
304
+ model: string
305
+ /** Plan mode state folded from the last `plan/mode` event. */
306
+ plan: boolean
307
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
308
+ permission: string
309
+ /** Latest session title folded from the last `session/title` event, empty before one. */
310
+ title: string
311
+ /**
312
+ * Effective system prompt assembled from `system/message` surface nodes
313
+ * (v3): the head node's text joined with every later non-empty node, blank
314
+ * lines between. Empty before the first system node or when every node is
315
+ * empty ("no system prompt").
316
+ */
317
+ systemPrompt: string
318
+ /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
319
+ sandbox: string
320
+ /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
321
+ goal: GoalFold | undefined
322
+ /**
323
+ * Ordered live message ids per inbox target, mirrored from
324
+ * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
325
+ * coordinates later removals resolve against.
326
+ */
327
+ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
328
+ /**
329
+ * Fold-internal timing anchors, never rendered: open step and tool-call
330
+ * start timestamps the next `assistant/message` / `tool/result` resolves
331
+ * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
332
+ * track which step/tool anchors still belong to the open turn so
333
+ * `turn/end` (and a superseding `step/start`) can sweep anchors an
334
+ * interruption left behind; `turnFiles` keys mutated paths by turn.
335
+ */
336
+ readonly anchors: {
337
+ stepStart: Map<string, number>
338
+ toolStart: Map<string, number>
339
+ firstChunkAt: Map<string, number>
340
+ compactionTokens: Map<string, number>
341
+ lastPruneTokens: number
342
+ turnFiles: Map<number, Set<string>>
343
+ turnSteps: Map<number, string>
344
+ turnTools: Map<number, Set<string>>
345
+ /** Live `system/message` surface nodes by event seq (empty string = an empty node). */
346
+ systemNodes: Map<number, string>
347
+ }
348
+ }
349
+
350
+ /** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
351
+ function assembleSystemPrompt(nodes: ReadonlyMap<number, string>): string {
352
+ if (nodes.size === 0) return ''
353
+ const ordered = [...nodes.entries()].sort((left, right) => left[0] - right[0])
354
+ const texts = ordered
355
+ .map(([, text]) => text)
356
+ .filter(text => text !== '')
357
+ return texts.join('\n\n')
358
+ }
359
+
360
+ /**
361
+ * Apply one surface event's replace to the live system nodes. Any surface
362
+ * event may shadow system nodes — the kernel's compaction summary lands as a
363
+ * `user/message` replace whose range can cover later system nodes (only node
364
+ * 0 is compaction-protected upstream) so every surface fold retires covered
365
+ * nodes, not just `system/message` itself.
366
+ * @param nodes - the live system-node map (mutated when the event replaces).
367
+ * @param surfaceOp - the surface operation the event carries, when it is a
368
+ * surface event (log-only events have none and change nothing).
369
+ * @returns the reassembled prompt when nodes were retired, `changed: false`
370
+ * when the event shadows nothing.
371
+ */
372
+ function retireShadowedSystemNodes(
373
+ nodes: Map<number, string>,
374
+ surfaceOp: 'append' | { readonly op: 'replace'; readonly startSeq: number; readonly endSeq: number } | undefined,
375
+ ): { readonly prompt: string; readonly changed: boolean } {
376
+ if (surfaceOp === undefined || surfaceOp === 'append') return { prompt: '', changed: false }
377
+ let changed = false
378
+ for (const seq of nodes.keys()) {
379
+ if (seq >= surfaceOp.startSeq && seq <= surfaceOp.endSeq) {
380
+ nodes.delete(seq)
381
+ changed = true
382
+ }
383
+ }
384
+ return changed ? { prompt: assembleSystemPrompt(nodes), changed } : { prompt: '', changed: false }
385
+ }
386
+
387
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
388
+ function textOf(content: readonly ContentBlock[]): string {
389
+ return content.filter(block => block.type === 'text').map(block => block.text).join('')
390
+ }
391
+
392
+ /**
393
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
394
+ * already handed to the renderer must never observe a later fold through a
395
+ * shared container. The collections are small and turn-bounded, so cloning
396
+ * per event is cheap next to the entries copy the reducer already makes.
397
+ */
398
+ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
399
+ return {
400
+ stepStart: new Map(anchors.stepStart),
401
+ toolStart: new Map(anchors.toolStart),
402
+ firstChunkAt: new Map(anchors.firstChunkAt),
403
+ compactionTokens: new Map(anchors.compactionTokens),
404
+ lastPruneTokens: anchors.lastPruneTokens,
405
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
406
+ turnSteps: new Map(anchors.turnSteps),
407
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
408
+ systemNodes: new Map(anchors.systemNodes),
409
+ }
410
+ }
411
+
412
+ /** Durable image references in their model-visible order. */
413
+ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
414
+ return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
415
+ }
416
+
417
+ /** Durable file references in their model-visible order. */
418
+ function filesOf(content: readonly ContentBlock[]): readonly FileAttachmentRef[] {
419
+ return content.filter((block): block is FileBlock => block.type === 'file').map(block => block.attachment)
420
+ }
421
+
422
+ /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
423
+ export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
424
+ if (images === undefined || images.length === 0) return ''
425
+ return images.map((image, index) => {
426
+ const rawName = image.name?.trim() || `image ${index + 1}`
427
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
428
+ const original = image.originalDimensions
429
+ const dimensions = original === undefined
430
+ ? `${image.width}×${image.height}`
431
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`
432
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
433
+ }).join('\n')
434
+ }
435
+
436
+ /** Human-readable bounded file labels for the same surfaces (0.1.5 file blocks). */
437
+ export function fileLabels(files: readonly FileAttachmentRef[] | undefined): string {
438
+ if (files === undefined || files.length === 0) return ''
439
+ return files.map((file, index) => {
440
+ const rawName = file.name?.trim() || `file ${index + 1}`
441
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
442
+ return `[file: ${name} · ${file.bytes} B]`
443
+ }).join('\n')
444
+ }
445
+
446
+ /** Prompt text with its durable image and file labels, without exposing local paths or bytes. */
447
+ export function promptDisplayText(entry: Pick<UserEntry | PendingEntry, 'text' | 'images' | 'files'>): string {
448
+ const imageText = imageLabels(entry.images)
449
+ const fileText = fileLabels(entry.files)
450
+ const labels = imageText === '' ? fileText : fileText === '' ? imageText : `${imageText}\n${fileText}`
451
+ return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`
452
+ }
453
+
454
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
455
+ function reasoningOf(content: readonly ContentBlock[]): string {
456
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
457
+ }
458
+
459
+ /**
460
+ * Rough token estimate for the segmented context bar (pi-nano-context's ~4
461
+ * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
462
+ * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
463
+ * drive bar PROPORTIONS, never billing, so precision is not required.
464
+ * @param text - the text to estimate.
465
+ * @returns an integer token estimate, 0 for empty text.
466
+ */
467
+ function estimateTokens(text: string): number {
468
+ let wide = 0
469
+ let narrow = 0
470
+ for (const cluster of splitGraphemes(text)) {
471
+ if (graphemeWidth(cluster) > 1) wide += 1
472
+ else narrow += 1
473
+ }
474
+ return wide + Math.ceil(narrow / 4)
475
+ }
476
+
477
+ /** A fresh, empty transcript view. */
478
+ export function createTranscriptView(): TranscriptView {
479
+ return {
480
+ entries: [],
481
+ streaming: '',
482
+ streamingReasoning: '',
483
+ todos: [],
484
+ toolCallOrdinal: 0,
485
+ busy: false,
486
+ busySince: 0,
487
+ model: '',
488
+ plan: false,
489
+ permission: '',
490
+ title: '',
491
+ systemPrompt: '',
492
+ sandbox: '',
493
+ goal: undefined,
494
+ pending: { 'next-turn': [], 'next-step': [] },
495
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
496
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
497
+ }
498
+ }
499
+
500
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
501
+ function pendingText(content: readonly ContentBlock[]): string {
502
+ return textOf(content)
503
+ }
504
+
505
+ /**
506
+ * Fold one session event into an updated view (copy-on-write).
507
+ * @param view - the view before the event.
508
+ * @param event - one durable session event from `session/event` or the log.
509
+ * @returns the view after the event; the input view is never mutated.
510
+ */
511
+ export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
512
+ // Fold against a private anchors block so the documented contract holds —
513
+ // "the input view is never mutated" — even for the in-place anchor sweeps
514
+ // below; without this, every handed-out view shared live Maps.
515
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) }
516
+ // Any surface event's replace may shadow system nodes (a compaction
517
+ // summary lands as a user/message replace whose range can cover later
518
+ // system nodes); the switch below folds against the shadowed view.
519
+ const shadow = retireShadowedSystemNodes(view.anchors.systemNodes, event.surfaceOp)
520
+ if (shadow.changed) {
521
+ view = {
522
+ ...view,
523
+ systemPrompt: shadow.prompt,
524
+ stats: { ...view.stats, contextSegments: { ...view.stats.contextSegments, system: estimateTokens(shadow.prompt) } },
525
+ }
526
+ }
527
+ switch (event.type) {
528
+ case 'user/message': {
529
+ // A queued row retires when its durable user message lands (the agent
530
+ // claims the inbox and logs the same message identity) — the transient
531
+ // steering/queued preview yields to the real transcript entry.
532
+ const message = event.data
533
+ let entries = view.entries
534
+ let pending = view.pending
535
+ for (const target of ['next-turn', 'next-step'] as const) {
536
+ const index = pending[target].indexOf(message.id)
537
+ if (index < 0) continue
538
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
539
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
540
+ }
541
+ // Injected context (plugin/model-continuation sources) stays collapsed
542
+ // to a bounded notice row, exactly like collapsed transcript context
543
+ // elsewhere in the product; only direct human prompts render in full.
544
+ const text = textOf(message.content)
545
+ const images = imagesOf(message.content)
546
+ const files = filesOf(message.content)
547
+ if (message.source.kind === 'user') {
548
+ return {
549
+ ...view,
550
+ pending,
551
+ entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
552
+ stats: {
553
+ ...view.stats,
554
+ contextSegments: {
555
+ ...view.stats.contextSegments,
556
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
557
+ },
558
+ },
559
+ }
560
+ }
561
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
562
+ ? message.source.summary
563
+ : message.source.kind
564
+ const summary = boundContextSummary(notice)
565
+ return {
566
+ ...view,
567
+ pending,
568
+ entries: [...entries, { kind: 'user', text: summary, notice: true }],
569
+ stats: {
570
+ ...view.stats,
571
+ contextSegments: {
572
+ ...view.stats.contextSegments,
573
+ system: view.stats.contextSegments.system + estimateTokens(summary),
574
+ },
575
+ },
576
+ }
577
+ }
578
+ case 'agent/inbox/spliced': {
579
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
580
+ // removals drop the projected rows at their inbox coordinates, inserted
581
+ // messages gain a pending row at their log position.
582
+ const { target, start, removedCount = 0, inserted } = event.data
583
+ const ids = view.pending[target]
584
+ const removed = ids.slice(start, start + removedCount)
585
+ // In-place upstream semantics: the kernel's authoritative fold is
586
+ // `inbox.splice(start, removedCount, ...inserted)` inserted ids land
587
+ // AT the splice position (prepend/replace shapes), never at the tail.
588
+ // A tail append diverged the id order, so later coordinate-based events
589
+ // (next-turn head claims, positioned remove/replace) tombstoned the
590
+ // wrong pending row.
591
+ const nextIds = [
592
+ ...ids.slice(0, start),
593
+ ...inserted.map(message => message.id),
594
+ ...ids.slice(start + removedCount),
595
+ ]
596
+ let entries = view.entries
597
+ if (removed.length > 0) {
598
+ const removedSet = new Set(removed)
599
+ entries = entries.filter(entry =>
600
+ !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
601
+ }
602
+ for (const message of inserted) {
603
+ entries = [...entries, {
604
+ kind: 'pending',
605
+ messageId: message.id,
606
+ target,
607
+ text: pendingText(message.content),
608
+ ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
609
+ ...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
610
+ }]
611
+ }
612
+ return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
613
+ }
614
+ case 'system/message': {
615
+ // Session-log v3 carries the system prompt as surface nodes (the
616
+ // `request/header.system` field is gone): append adds one node;
617
+ // replace(startSeq, endSeq) retires the covered nodes and this event's
618
+ // node takes their place. The effective prompt is the head text joined
619
+ // with every later non-empty node (kernel in-history assembly), and
620
+ // the context estimate prices that assembly — a tail-clearing
621
+ // replacement never zeroes a surviving head.
622
+ const text = textOf(event.data.message.content)
623
+ const nodes = view.anchors.systemNodes
624
+ retireShadowedSystemNodes(nodes, event.surfaceOp)
625
+ nodes.set(event.seq, text)
626
+ const systemPrompt = assembleSystemPrompt(nodes)
627
+ return {
628
+ ...view,
629
+ systemPrompt,
630
+ stats: {
631
+ ...view.stats,
632
+ contextSegments: {
633
+ ...view.stats.contextSegments,
634
+ system: estimateTokens(systemPrompt),
635
+ },
636
+ },
637
+ }
638
+ }
639
+ case 'assistant/attempt': {
640
+ // A failed, retried, cancelled, or stream-error attempt that produced no
641
+ // surface message (session-log v2+ folds its chunk stream in here). The
642
+ // step is NOT closed the kernel's session-stats keeps one step start
643
+ // across in-step retries, so llmMs spans them; keep the anchors so a
644
+ // retrying attempt and its final settlement time the step from one
645
+ // start. An attempt whose first token streamed only live (the frames
646
+ // died before the fold) restores its first-token anchor from the
647
+ // embedded stream exactly like a replayed one.
648
+ const key = `${event.data.turn}:${event.data.step}`
649
+ let stats = view.stats
650
+ if (!view.anchors.firstChunkAt.has(key)) {
651
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
652
+ if (first !== undefined) {
653
+ view.anchors.firstChunkAt.set(key, first)
654
+ const started = view.anchors.stepStart.get(key)
655
+ if (started !== undefined) {
656
+ stats = {
657
+ ...stats,
658
+ ttftMs: stats.ttftMs + Math.max(0, first - started),
659
+ ttftSteps: stats.ttftSteps + 1,
660
+ }
661
+ }
662
+ }
663
+ }
664
+ if (view.streaming === '' && view.streamingReasoning === '' && stats === view.stats) return view
665
+ return { ...view, streaming: '', streamingReasoning: '', stats }
666
+ }
667
+ case 'assistant/message': {
668
+ // The assembled message is authoritative; drop the streamed buffers.
669
+ const key = `${event.data.turn}:${event.data.step}`
670
+ const started = view.anchors.stepStart.get(key)
671
+ view.anchors.stepStart.delete(key)
672
+ // Live streaming anchored the first token through the process-local
673
+ // assistant-stream frames; a replayed settlement has no live frames, so
674
+ // the embedded stream's own first-token time restores the same anchor
675
+ // AND the TTFT figures live frames accumulate on the live path.
676
+ let firstChunk = view.anchors.firstChunkAt.get(key)
677
+ let stats = view.stats
678
+ if (firstChunk === undefined) {
679
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
680
+ if (firstChunk !== undefined && started !== undefined) {
681
+ stats = {
682
+ ...stats,
683
+ ttftMs: stats.ttftMs + Math.max(0, firstChunk - started),
684
+ ttftSteps: stats.ttftSteps + 1,
685
+ }
686
+ }
687
+ }
688
+ view.anchors.firstChunkAt.delete(key)
689
+ // The assembled message consumes the turn's current step anchor; a
690
+ // later `turn/end` sweep then has nothing left to clean for this step.
691
+ if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
692
+ const usage = event.data.usage
693
+ const totals = stats.usage
694
+ const text = textOf(event.data.message.content)
695
+ const reasoning = reasoningOf(event.data.message.content)
696
+ return {
697
+ ...view,
698
+ streaming: '',
699
+ streamingReasoning: '',
700
+ entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
701
+ stats: {
702
+ ...stats,
703
+ llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
704
+ usage: usage === undefined ? totals : {
705
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
706
+ outputTokens: totals.outputTokens + usage.outputTokens,
707
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
708
+ },
709
+ lastPromptTokens: usage === undefined ? stats.lastPromptTokens
710
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
711
+ // Decode span and its tokens pair up: an un-timed step (no first
712
+ // chunk landed) contributes neither, so the rate stays honest.
713
+ decodeMs: stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
714
+ decodeTokens: stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
715
+ contextSegments: {
716
+ ...stats.contextSegments,
717
+ thinking: stats.contextSegments.thinking + estimateTokens(reasoning),
718
+ assistant: stats.contextSegments.assistant + estimateTokens(text),
719
+ },
720
+ },
721
+ }
722
+ }
723
+ case 'tool/call': {
724
+ const data = event.data
725
+ view.anchors.toolStart.set(data.callId, event.time)
726
+ // Remember the call's turn so `turn/end` can sweep a start that never
727
+ // pairs with a result (an interrupted tool otherwise leaks its anchor).
728
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
729
+ turnTools.add(data.callId)
730
+ view.anchors.turnTools.set(data.turn, turnTools)
731
+ const ordinal = view.toolCallOrdinal + 1
732
+ return {
733
+ ...view,
734
+ toolCallOrdinal: ordinal,
735
+ entries: [
736
+ ...view.entries,
737
+ {
738
+ kind: 'tool',
739
+ callId: data.callId,
740
+ ordinal,
741
+ name: data.name,
742
+ arguments: data.arguments,
743
+ preview: toolArgumentsPreview(data.arguments, data.name),
744
+ prompt: toolPromptPreview(data.name, data.arguments),
745
+ state: 'running',
746
+ summary: '',
747
+ detail: undefined,
748
+ }],
749
+ stats: {
750
+ ...view.stats,
751
+ contextSegments: {
752
+ ...view.stats.contextSegments,
753
+ tools: view.stats.contextSegments.tools
754
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
755
+ },
756
+ },
757
+ }
758
+ }
759
+ case 'tool/result': {
760
+ const block = event.data.message.content[0]
761
+ const started = view.anchors.toolStart.get(block.toolCallId)
762
+ view.anchors.toolStart.delete(block.toolCallId)
763
+ // Deregister the call from its turn's registry so `turn/end` does not
764
+ // sweep a start that already paired with a result.
765
+ const turnTools = view.anchors.turnTools.get(event.data.turn)
766
+ if (turnTools !== undefined) {
767
+ turnTools.delete(block.toolCallId)
768
+ if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
769
+ }
770
+ const rawText = textOf(block.content)
771
+ const summary = boundContextSummary(rawText)
772
+ // The verbose expansion self-serves from the persisted presentation
773
+ // metadata (diffs, read windows, web sources) with the bounded raw text
774
+ // as the universal fallback — the capable-UI degradation ladder.
775
+ const detail = toolResultDetail(event.data.meta, rawText)
776
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
777
+ if (detail?.kind === 'diff') {
778
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
779
+ for (const diff of detail.diffs) set.add(diff.path)
780
+ view.anchors.turnFiles.set(event.data.turn, set)
781
+ }
782
+ const entries = view.entries.map((entry) => {
783
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
784
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
785
+ })
786
+ return {
787
+ ...view,
788
+ entries,
789
+ stats: {
790
+ ...view.stats,
791
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
792
+ contextSegments: {
793
+ ...view.stats.contextSegments,
794
+ tools: view.stats.contextSegments.tools + estimateTokens(rawText),
795
+ },
796
+ },
797
+ }
798
+ }
799
+ case 'todo/write':
800
+ return { ...view, todos: event.data.todos }
801
+ case 'turn/start':
802
+ // The web todo projection clears on turn/start: a fresh turn's first
803
+ // write is the authoritative list, and a stale snapshot must not linger
804
+ // through a turn that has not written one yet.
805
+ return {
806
+ ...view,
807
+ busy: true,
808
+ busySince: view.busy ? view.busySince : event.time,
809
+ todos: [],
810
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
811
+ }
812
+ case 'step/start': {
813
+ // A step supersedes the turn's previous step: if that step never
814
+ // assembled a message (interrupted), its timing anchors are stale the
815
+ // moment the next step opens and are swept here instead of leaking.
816
+ const key = `${event.data.turn}:${event.data.step}`
817
+ const previous = view.anchors.turnSteps.get(event.data.turn)
818
+ if (previous !== undefined && previous !== key) {
819
+ view.anchors.stepStart.delete(previous)
820
+ view.anchors.firstChunkAt.delete(previous)
821
+ }
822
+ view.anchors.turnSteps.set(event.data.turn, key)
823
+ view.anchors.stepStart.set(key, event.time)
824
+ return {
825
+ ...view,
826
+ streaming: '',
827
+ streamingReasoning: '',
828
+ stats: { ...view.stats, steps: view.stats.steps + 1 },
829
+ }
830
+ }
831
+ case 'turn/end': {
832
+ const reason = event.data.reason
833
+ const appended: TranscriptEntry[] = []
834
+ if (reason.kind === 'error') {
835
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
836
+ ? ' · open /model to add an API key'
837
+ : ''
838
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
839
+ } else {
840
+ // Non-error outcomes deserve their own durable row (the web renders
841
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
842
+ // silent so an ordinary turn never grows a marker.
843
+ const marker = reason.kind === 'aborted'
844
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
845
+ : reason.kind === 'max-tokens'
846
+ ? 'turn hit the output-token ceiling (max-tokens)'
847
+ : reason.kind === 'blocked'
848
+ ? 'turn ended blocked'
849
+ : reason.kind === 'interrupted'
850
+ ? 'turn was interrupted by a restart'
851
+ : undefined
852
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
853
+ }
854
+ // Deliverables ride the turn tail (the web's turnTail chips): the
855
+ // turn's mutated files flush as one bounded row, then the set resets.
856
+ const files = view.anchors.turnFiles.get(event.data.turn)
857
+ view.anchors.turnFiles.delete(event.data.turn)
858
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
859
+ // Derivable boundary sweep: the turn is over, so any step/tool anchors
860
+ // it left behind (interruptions that never produced their message or
861
+ // result) can never be resolved and are reclaimed now.
862
+ const stepKey = view.anchors.turnSteps.get(event.data.turn)
863
+ if (stepKey !== undefined) {
864
+ view.anchors.stepStart.delete(stepKey)
865
+ view.anchors.firstChunkAt.delete(stepKey)
866
+ view.anchors.turnSteps.delete(event.data.turn)
867
+ }
868
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn)
869
+ if (turnToolSet !== undefined) {
870
+ for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
871
+ view.anchors.turnTools.delete(event.data.turn)
872
+ }
873
+ // Orphaned retry/command rows can never be resolved after the turn
874
+ // ends: an aborted retry backoff returns upstream without its
875
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
876
+ // turn closers. Left `running` they pin the settled boundary forever,
877
+ // so the turn end finalizes them exactly like the anchor sweep above.
878
+ let orphans = false
879
+ const swept = view.entries.map((entry) => {
880
+ if (entry.kind === 'retry' && entry.state === 'running') {
881
+ orphans = true
882
+ return { ...entry, state: 'done' as const }
883
+ }
884
+ if (entry.kind === 'command' && entry.state === 'running') {
885
+ orphans = true
886
+ return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
887
+ }
888
+ return entry
889
+ })
890
+ const entries = orphans ? swept : view.entries
891
+ if (appended.length === 0) {
892
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
893
+ }
894
+ return {
895
+ ...view,
896
+ busy: false,
897
+ busySince: 0,
898
+ streaming: '',
899
+ streamingReasoning: '',
900
+ entries: [...entries, ...appended],
901
+ }
902
+ }
903
+ case 'llm/retry': {
904
+ const data = event.data
905
+ return {
906
+ ...view,
907
+ streaming: '',
908
+ streamingReasoning: '',
909
+ entries: [...view.entries, {
910
+ kind: 'retry',
911
+ retryId: data.retryId,
912
+ mode: data.mode,
913
+ attempt: data.retry,
914
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
915
+ code: data.failure.code,
916
+ delayMs: data.delayMs,
917
+ state: 'running',
918
+ }],
919
+ }
920
+ }
921
+ case 'llm/retry-started': {
922
+ const data = event.data
923
+ const entries = view.entries.map((entry) => {
924
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
925
+ return { ...entry, state: 'done' as const }
926
+ })
927
+ return { ...view, entries }
928
+ }
929
+ case 'sandbox/mode':
930
+ // Log-only override switch; last write wins for the status badge.
931
+ return { ...view, sandbox: event.data.mode }
932
+ case 'goal/change': {
933
+ const data = event.data
934
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
935
+ if (data.operation === 'clear') {
936
+ return {
937
+ ...view,
938
+ goal: undefined,
939
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
940
+ }
941
+ }
942
+ const goal: GoalFold = {
943
+ objective: data.goal.objective,
944
+ phase: data.goal.phase,
945
+ rounds: data.roundsStarted,
946
+ max: data.goal.maxGoalRounds,
947
+ blocked: data.goal.blockedReason?.message ?? '',
948
+ }
949
+ const line = data.operation === 'create'
950
+ ? `◎ goal: ${clip(data.goal.objective)}`
951
+ : data.operation === 'complete'
952
+ ? '◎ goal complete'
953
+ : data.operation === 'pause'
954
+ ? '◎ goal paused'
955
+ : data.operation === 'resume'
956
+ ? '◎ goal resumed'
957
+ : data.operation === 'block'
958
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
959
+ : undefined
960
+ return {
961
+ ...view,
962
+ goal,
963
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
964
+ }
965
+ }
966
+ case 'session/title':
967
+ // Latest-wins title snapshot, log-only; the status line prefers it.
968
+ return { ...view, title: event.data.title }
969
+ case 'compaction/summary':
970
+ // Remember the shadow price so the matching `compaction/end` row can
971
+ // state what the compaction reclaimed. The map is capped so an aborted
972
+ // compaction (summary without end) cannot leave an unbounded residue.
973
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
974
+ const oldest = view.anchors.compactionTokens.keys().next().value
975
+ if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
976
+ }
977
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
978
+ return view
979
+ case 'compaction/prune':
980
+ // A model-free prune carries no compaction id; its price serves the next
981
+ // `compaction/end` that cannot find a summary price.
982
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
983
+ case 'compaction/end': {
984
+ const ok = event.data.error === undefined
985
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
986
+ view.anchors.compactionTokens.delete(event.data.compactionId)
987
+ return {
988
+ ...view,
989
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
990
+ }
991
+ }
992
+ case 'request/context':
993
+ // Route capacity, logged only when it changes; last one wins.
994
+ return {
995
+ ...view,
996
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
997
+ }
998
+ case 'request/header': {
999
+ // The session's own model record: the latest snapshot's provider/model
1000
+ // pair, exactly what a resumed TUI restores as the selection, plus the
1001
+ // effective reasoning effort that snapshot carried (the adapter may
1002
+ // materialize the model default, which is what the status line shows).
1003
+ // The system-prompt estimate lives with `system/message` events since
1004
+ // session-log v3 removed the header's `system` field.
1005
+ const config = event.data.header.config
1006
+ return {
1007
+ ...view,
1008
+ model: `${config.provider}/${config.model}`,
1009
+ stats: {
1010
+ ...view.stats,
1011
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1012
+ },
1013
+ }
1014
+ }
1015
+ case 'plan/mode':
1016
+ // Whole-value replace; the last one wins (upstream fold semantics).
1017
+ return { ...view, plan: event.data.active }
1018
+ case 'permission/preset':
1019
+ return { ...view, permission: event.data.preset }
1020
+ case 'command/run': {
1021
+ const data = event.data
1022
+ return {
1023
+ ...view,
1024
+ entries: [...view.entries, {
1025
+ kind: 'command',
1026
+ commandId: data.commandId,
1027
+ name: data.name,
1028
+ args: data.args ?? '',
1029
+ state: 'running',
1030
+ summary: '',
1031
+ }],
1032
+ }
1033
+ }
1034
+ case 'command/done': {
1035
+ const data = event.data
1036
+ const entries = view.entries.map((entry) => {
1037
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
1038
+ return {
1039
+ ...entry,
1040
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1041
+ summary: boundContextSummary(data.text ?? ''),
1042
+ }
1043
+ })
1044
+ return { ...view, entries }
1045
+ }
1046
+ default:
1047
+ return view
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * Mutable replay accumulator: folds a persisted log into the identical view
1053
+ * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
1054
+ * is copy-on-write every append/scan rebuilds the whole `entries` array, so
1055
+ * folding a full log costs O(N²) the accumulator appends by push, resolves
1056
+ * id-keyed updates (tool/result, command/done, retry-started) through index
1057
+ * maps, and tombstones retired pending rows, so the whole log folds in O(N)
1058
+ * plus one compaction pass when tombstones exist.
1059
+ *
1060
+ * Index maps never delete: every appended row registers its index, so an id
1061
+ * lookup miss provably means no matching row exists and the update is an O(1)
1062
+ * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
1063
+ * scans). Each id maps to ALL of its indices, so a duplicate id updates every
1064
+ * matching row exactly like the copy-on-write reducer.
1065
+ *
1066
+ * @internal Exported only so tests can (a) prove replay ≡ sequential
1067
+ * `projectEvent` folds and (b) assert the linear complexity deterministically
1068
+ * via {@link ReplayAccumulator.ops}, which counts entry-level container work
1069
+ * instead of relying on wall-clock thresholds. No public consumer.
1070
+ */
1071
+ export interface ReplayAccumulator {
1072
+ /** Working entry list; `undefined` marks a retired pending row (tombstone). */
1073
+ entries: (TranscriptEntry | undefined)[]
1074
+ /** callId → every index into `entries` holding a `tool` row with that id. */
1075
+ toolIndex: Map<string, number[]>
1076
+ /** commandId → every index into `entries` holding a `command` row with that id. */
1077
+ commandIndex: Map<string, number[]>
1078
+ /** retryId every index into `entries` holding a `retry` row with that id. */
1079
+ retryIndex: Map<string, number[]>
1080
+ /** messageId every index into `entries` holding a `pending` row with that id. */
1081
+ pendingIndex: Map<string, number[]>
1082
+ /** Tombstone count; zero means `entries` is already the final array. */
1083
+ removedCount: number
1084
+ /** Mutable inbox id lists, mirroring `view.pending` order per target. */
1085
+ pendingTurn: string[]
1086
+ pendingStep: string[]
1087
+ streaming: string
1088
+ streamingReasoning: string
1089
+ todos: readonly TodoItem[]
1090
+ /** Global tool-call ordinal counter (see `TranscriptView.toolCallOrdinal`). */
1091
+ toolCallOrdinal: number
1092
+ busy: boolean
1093
+ busySince: number
1094
+ model: string
1095
+ plan: boolean
1096
+ permission: string
1097
+ title: string
1098
+ systemPrompt: string
1099
+ sandbox: string
1100
+ goal: GoalFold | undefined
1101
+ stats: TranscriptStats
1102
+ stepStart: Map<string, number>
1103
+ toolStart: Map<string, number>
1104
+ firstChunkAt: Map<string, number>
1105
+ compactionTokens: Map<string, number>
1106
+ lastPruneTokens: number
1107
+ turnFiles: Map<number, Set<string>>
1108
+ turnSteps: Map<number, string>
1109
+ turnTools: Map<number, Set<string>>
1110
+ /** Live `system/message` surface nodes by event seq (empty string = an empty node). */
1111
+ systemNodes: Map<number, string>
1112
+ /** Entry-level container operations performed so far (test instrumentation). */
1113
+ ops: number
1114
+ }
1115
+
1116
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
1117
+ export function createReplayAccumulator(): ReplayAccumulator {
1118
+ return {
1119
+ entries: [],
1120
+ toolIndex: new Map(),
1121
+ commandIndex: new Map(),
1122
+ retryIndex: new Map(),
1123
+ pendingIndex: new Map(),
1124
+ removedCount: 0,
1125
+ pendingTurn: [],
1126
+ pendingStep: [],
1127
+ streaming: '',
1128
+ streamingReasoning: '',
1129
+ todos: [],
1130
+ toolCallOrdinal: 0,
1131
+ busy: false,
1132
+ busySince: 0,
1133
+ model: '',
1134
+ plan: false,
1135
+ permission: '',
1136
+ title: '',
1137
+ systemPrompt: '',
1138
+ sandbox: '',
1139
+ goal: undefined,
1140
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
1141
+ stepStart: new Map(),
1142
+ toolStart: new Map(),
1143
+ firstChunkAt: new Map(),
1144
+ compactionTokens: new Map(),
1145
+ lastPruneTokens: 0,
1146
+ turnFiles: new Map(),
1147
+ turnSteps: new Map(),
1148
+ turnTools: new Map(),
1149
+ systemNodes: new Map(),
1150
+ ops: 0,
1151
+ }
1152
+ }
1153
+
1154
+ /** Append one entry (O(1)) and account the push. */
1155
+ function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
1156
+ acc.entries.push(entry)
1157
+ acc.ops += 1
1158
+ }
1159
+
1160
+ /**
1161
+ * Get (or create) the index list an id owns. Lists are never removed: every
1162
+ * appended row registers its index, so a lookup miss later proves no matching
1163
+ * row exists and the caller can no-op in O(1).
1164
+ */
1165
+ function indexList(map: Map<string, number[]>, id: string): number[] {
1166
+ let list = map.get(id)
1167
+ if (list === undefined) {
1168
+ list = []
1169
+ map.set(id, list)
1170
+ }
1171
+ return list
1172
+ }
1173
+
1174
+ /**
1175
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1176
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1177
+ * settles `error` with an interruption note. Only the id-indexed rows are
1178
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1179
+ */
1180
+ function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1181
+ for (const list of acc.retryIndex.values()) {
1182
+ for (const index of list) {
1183
+ const entry = acc.entries[index]
1184
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1185
+ acc.entries[index] = { ...entry, state: 'done' }
1186
+ }
1187
+ }
1188
+ }
1189
+ for (const list of acc.commandIndex.values()) {
1190
+ for (const index of list) {
1191
+ const entry = acc.entries[index]
1192
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1193
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * Apply an id-keyed update to every row that registered the id, mirroring the
1201
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
1202
+ * in order). Each registered index is O(1), so a duplicate id costs
1203
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
1204
+ * registered indices are valid by construction, because tool/command/retry
1205
+ * rows are never removed and tombstones never shift indices.
1206
+ */
1207
+ function updateReplayById<T extends TranscriptEntry>(
1208
+ acc: ReplayAccumulator,
1209
+ map: Map<string, number[]>,
1210
+ id: string,
1211
+ isMatch: (entry: T) => boolean,
1212
+ update: (entry: T) => T,
1213
+ ): void {
1214
+ const list = map.get(id)
1215
+ if (list === undefined) return // miss provably means no matching row
1216
+ for (const index of list) {
1217
+ const entry = acc.entries[index]
1218
+ if (entry === undefined || !isMatch(entry as T)) continue
1219
+ acc.entries[index] = update(entry as T)
1220
+ acc.ops += 1
1221
+ }
1222
+ }
1223
+
1224
+ /** Tombstone a retired pending row, keeping every other index stable. */
1225
+ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1226
+ if (acc.entries[index] !== undefined) {
1227
+ acc.entries[index] = undefined
1228
+ acc.removedCount += 1
1229
+ acc.ops += 1
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Fold one session event into a replay accumulator. This mirrors
1235
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
1236
+ * set/delete behavior, same entry shapes so the finished view is identical
1237
+ * to a sequential fold; only the `entries` container operations are mutable.
1238
+ *
1239
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1240
+ * @returns whether the event changed the accumulated state — the live store
1241
+ * stays silent and keeps its snapshot identity for ignored events, exactly
1242
+ * like the copy-on-write reducer returning its input view unchanged.
1243
+ */
1244
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1245
+ // Mirror of the reducer's entry shadow: any surface replace retires the
1246
+ // system nodes it covers before the per-event fold runs.
1247
+ const shadow = retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
1248
+ if (shadow.changed) {
1249
+ acc.systemPrompt = shadow.prompt
1250
+ acc.stats = { ...acc.stats, contextSegments: { ...acc.stats.contextSegments, system: estimateTokens(shadow.prompt) } }
1251
+ }
1252
+ switch (event.type) {
1253
+ case 'user/message': {
1254
+ const message = event.data
1255
+ for (const target of ['next-turn', 'next-step'] as const) {
1256
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1257
+ const index = ids.indexOf(message.id)
1258
+ acc.ops += index < 0 ? ids.length : index + 1
1259
+ if (index < 0) continue
1260
+ ids.splice(index, 1)
1261
+ acc.ops += 1
1262
+ // Retire every pending row carrying this message id (duplicate ids
1263
+ // included), exactly like the reducer's full-array filter.
1264
+ const list = acc.pendingIndex.get(message.id)
1265
+ if (list !== undefined) {
1266
+ for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
1267
+ acc.ops += 1
1268
+ }
1269
+ }
1270
+ const text = textOf(message.content)
1271
+ const images = imagesOf(message.content)
1272
+ const files = filesOf(message.content)
1273
+ if (message.source.kind === 'user') {
1274
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1275
+ acc.stats = {
1276
+ ...acc.stats,
1277
+ contextSegments: {
1278
+ ...acc.stats.contextSegments,
1279
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1280
+ },
1281
+ }
1282
+ return true
1283
+ }
1284
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1285
+ ? message.source.summary
1286
+ : message.source.kind
1287
+ const summary = boundContextSummary(notice)
1288
+ appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1289
+ acc.stats = {
1290
+ ...acc.stats,
1291
+ contextSegments: {
1292
+ ...acc.stats.contextSegments,
1293
+ system: acc.stats.contextSegments.system + estimateTokens(summary),
1294
+ },
1295
+ }
1296
+ return true
1297
+ }
1298
+ case 'agent/inbox/spliced': {
1299
+ const { target, start, removedCount = 0, inserted } = event.data
1300
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1301
+ const removed = ids.slice(start, start + removedCount)
1302
+ acc.ops += removed.length
1303
+ ids.splice(start, removedCount)
1304
+ acc.ops += removed.length
1305
+ for (const id of removed) {
1306
+ const list = acc.pendingIndex.get(id)
1307
+ if (list === undefined) continue
1308
+ for (const entryIndex of list) {
1309
+ const entry = acc.entries[entryIndex]
1310
+ if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1311
+ retireReplayEntry(acc, entryIndex)
1312
+ }
1313
+ }
1314
+ }
1315
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1316
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1317
+ // at the tail — the id list must stay coordinate-compatible with every
1318
+ // later inbox event.
1319
+ ids.splice(start, 0, ...inserted.map(message => message.id))
1320
+ for (const message of inserted) {
1321
+ const images = imagesOf(message.content)
1322
+ const files = filesOf(message.content)
1323
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1324
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1325
+ acc.ops += 1
1326
+ }
1327
+ return true
1328
+ }
1329
+ case 'system/message': {
1330
+ // Mirrors the reducer's per-node fold: append adds, replace retires the
1331
+ // covered seq range, and the estimate prices the assembled prompt.
1332
+ const text = textOf(event.data.message.content)
1333
+ retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp)
1334
+ acc.systemNodes.set(event.seq, text)
1335
+ acc.systemPrompt = assembleSystemPrompt(acc.systemNodes)
1336
+ acc.stats = {
1337
+ ...acc.stats,
1338
+ contextSegments: {
1339
+ ...acc.stats.contextSegments,
1340
+ system: estimateTokens(acc.systemPrompt),
1341
+ },
1342
+ }
1343
+ return true
1344
+ }
1345
+ case 'assistant/attempt': {
1346
+ // Mirrors the reducer: the step stays open across in-step retries; a
1347
+ // missing first-token anchor is restored (and accrued) from the
1348
+ // attempt's embedded stream, and only the live tails are dropped.
1349
+ const key = `${event.data.turn}:${event.data.step}`
1350
+ let changed = false
1351
+ if (!acc.firstChunkAt.has(key)) {
1352
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? [])
1353
+ if (first !== undefined) {
1354
+ acc.firstChunkAt.set(key, first)
1355
+ const started = acc.stepStart.get(key)
1356
+ if (started !== undefined) {
1357
+ acc.stats = {
1358
+ ...acc.stats,
1359
+ ttftMs: acc.stats.ttftMs + Math.max(0, first - started),
1360
+ ttftSteps: acc.stats.ttftSteps + 1,
1361
+ }
1362
+ }
1363
+ changed = true
1364
+ }
1365
+ }
1366
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
1367
+ acc.streaming = ''
1368
+ acc.streamingReasoning = ''
1369
+ return changed || streamed
1370
+ }
1371
+ case 'assistant/message': {
1372
+ const key = `${event.data.turn}:${event.data.step}`
1373
+ const started = acc.stepStart.get(key)
1374
+ acc.stepStart.delete(key)
1375
+ let firstChunk = acc.firstChunkAt.get(key)
1376
+ if (firstChunk === undefined) {
1377
+ // A replayed settlement has no live frames; the embedded stream's
1378
+ // first-token time restores both the anchor and the TTFT figures the
1379
+ // live path accumulates in applyAssistantStreamChunk.
1380
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? [])
1381
+ if (firstChunk !== undefined && started !== undefined) {
1382
+ acc.stats = {
1383
+ ...acc.stats,
1384
+ ttftMs: acc.stats.ttftMs + Math.max(0, firstChunk - started),
1385
+ ttftSteps: acc.stats.ttftSteps + 1,
1386
+ }
1387
+ }
1388
+ }
1389
+ acc.firstChunkAt.delete(key)
1390
+ if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1391
+ const usage = event.data.usage
1392
+ const totals = acc.stats.usage
1393
+ const text = textOf(event.data.message.content)
1394
+ const reasoning = reasoningOf(event.data.message.content)
1395
+ acc.streaming = ''
1396
+ acc.streamingReasoning = ''
1397
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1398
+ acc.stats = {
1399
+ ...acc.stats,
1400
+ llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1401
+ usage: usage === undefined ? totals : {
1402
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1403
+ outputTokens: totals.outputTokens + usage.outputTokens,
1404
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1405
+ },
1406
+ lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1407
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1408
+ decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1409
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1410
+ contextSegments: {
1411
+ ...acc.stats.contextSegments,
1412
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1413
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1414
+ },
1415
+ }
1416
+ return true
1417
+ }
1418
+ case 'tool/call': {
1419
+ const data = event.data
1420
+ acc.toolStart.set(data.callId, event.time)
1421
+ const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1422
+ turnTools.add(data.callId)
1423
+ acc.turnTools.set(data.turn, turnTools)
1424
+ acc.toolCallOrdinal += 1
1425
+ appendReplayEntry(acc, {
1426
+ kind: 'tool',
1427
+ callId: data.callId,
1428
+ ordinal: acc.toolCallOrdinal,
1429
+ name: data.name,
1430
+ arguments: data.arguments,
1431
+ preview: toolArgumentsPreview(data.arguments, data.name),
1432
+ prompt: toolPromptPreview(data.name, data.arguments),
1433
+ state: 'running',
1434
+ summary: '',
1435
+ detail: undefined,
1436
+ })
1437
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1438
+ acc.stats = {
1439
+ ...acc.stats,
1440
+ contextSegments: {
1441
+ ...acc.stats.contextSegments,
1442
+ tools: acc.stats.contextSegments.tools
1443
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1444
+ },
1445
+ }
1446
+ return true
1447
+ }
1448
+ case 'tool/result': {
1449
+ const block = event.data.message.content[0]
1450
+ const started = acc.toolStart.get(block.toolCallId)
1451
+ acc.toolStart.delete(block.toolCallId)
1452
+ const turnTools = acc.turnTools.get(event.data.turn)
1453
+ if (turnTools !== undefined) {
1454
+ turnTools.delete(block.toolCallId)
1455
+ if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1456
+ }
1457
+ const rawText = textOf(block.content)
1458
+ const summary = boundContextSummary(rawText)
1459
+ const detail = toolResultDetail(event.data.meta, rawText)
1460
+ if (detail?.kind === 'diff') {
1461
+ const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1462
+ for (const diff of detail.diffs) set.add(diff.path)
1463
+ acc.turnFiles.set(event.data.turn, set)
1464
+ }
1465
+ const update = (entry: ToolEntry): ToolEntry => ({
1466
+ ...entry,
1467
+ state: block.isError === true ? 'error' as const : 'done' as const,
1468
+ summary,
1469
+ detail,
1470
+ })
1471
+ // Every matching row updates (duplicate callIds included); an id with no
1472
+ // registered index is a provable no-op — no full-array fallback scan.
1473
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1474
+ acc.stats = {
1475
+ ...acc.stats,
1476
+ toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1477
+ contextSegments: {
1478
+ ...acc.stats.contextSegments,
1479
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1480
+ },
1481
+ }
1482
+ return true
1483
+ }
1484
+ case 'todo/write':
1485
+ acc.todos = event.data.todos
1486
+ return true
1487
+ case 'turn/start': {
1488
+ const wasBusy = acc.busy
1489
+ acc.busy = true
1490
+ acc.busySince = wasBusy ? acc.busySince : event.time
1491
+ acc.todos = []
1492
+ acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1493
+ return true
1494
+ }
1495
+ case 'step/start': {
1496
+ const key = `${event.data.turn}:${event.data.step}`
1497
+ const previous = acc.turnSteps.get(event.data.turn)
1498
+ if (previous !== undefined && previous !== key) {
1499
+ acc.stepStart.delete(previous)
1500
+ acc.firstChunkAt.delete(previous)
1501
+ }
1502
+ acc.turnSteps.set(event.data.turn, key)
1503
+ acc.stepStart.set(key, event.time)
1504
+ acc.streaming = ''
1505
+ acc.streamingReasoning = ''
1506
+ acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1507
+ return true
1508
+ }
1509
+ case 'turn/end': {
1510
+ const reason = event.data.reason
1511
+ const appended: TranscriptEntry[] = []
1512
+ acc.streamingReasoning = ''
1513
+ acc.streaming = ''
1514
+ if (reason.kind === 'error') {
1515
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1516
+ ? ' · open /model to add an API key'
1517
+ : ''
1518
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1519
+ } else {
1520
+ const marker = reason.kind === 'aborted'
1521
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1522
+ : reason.kind === 'max-tokens'
1523
+ ? 'turn hit the output-token ceiling (max-tokens)'
1524
+ : reason.kind === 'blocked'
1525
+ ? 'turn ended blocked'
1526
+ : reason.kind === 'interrupted'
1527
+ ? 'turn was interrupted by a restart'
1528
+ : undefined
1529
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1530
+ }
1531
+ const files = acc.turnFiles.get(event.data.turn)
1532
+ acc.turnFiles.delete(event.data.turn)
1533
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1534
+ const stepKey = acc.turnSteps.get(event.data.turn)
1535
+ if (stepKey !== undefined) {
1536
+ acc.stepStart.delete(stepKey)
1537
+ acc.firstChunkAt.delete(stepKey)
1538
+ acc.turnSteps.delete(event.data.turn)
1539
+ }
1540
+ const turnToolSet = acc.turnTools.get(event.data.turn)
1541
+ if (turnToolSet !== undefined) {
1542
+ for (const callId of turnToolSet) acc.toolStart.delete(callId)
1543
+ acc.turnTools.delete(event.data.turn)
1544
+ }
1545
+ finalizeReplayOrphans(acc)
1546
+ acc.busy = false
1547
+ acc.busySince = 0
1548
+ for (const entry of appended) appendReplayEntry(acc, entry)
1549
+ return true
1550
+ }
1551
+ case 'llm/retry': {
1552
+ const data = event.data
1553
+ acc.streaming = ''
1554
+ acc.streamingReasoning = ''
1555
+ appendReplayEntry(acc, {
1556
+ kind: 'retry',
1557
+ retryId: data.retryId,
1558
+ mode: data.mode,
1559
+ attempt: data.retry,
1560
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1561
+ code: data.failure.code,
1562
+ delayMs: data.delayMs,
1563
+ state: 'running',
1564
+ })
1565
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1566
+ return true
1567
+ }
1568
+ case 'llm/retry-started': {
1569
+ const data = event.data
1570
+ updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1571
+ return true
1572
+ }
1573
+ case 'sandbox/mode':
1574
+ acc.sandbox = event.data.mode
1575
+ return true
1576
+ case 'goal/change': {
1577
+ const data = event.data
1578
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1579
+ if (data.operation === 'clear') {
1580
+ acc.goal = undefined
1581
+ appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1582
+ return true
1583
+ }
1584
+ const goal: GoalFold = {
1585
+ objective: data.goal.objective,
1586
+ phase: data.goal.phase,
1587
+ rounds: data.roundsStarted,
1588
+ max: data.goal.maxGoalRounds,
1589
+ blocked: data.goal.blockedReason?.message ?? '',
1590
+ }
1591
+ const line = data.operation === 'create'
1592
+ ? `◎ goal: ${clip(data.goal.objective)}`
1593
+ : data.operation === 'complete'
1594
+ ? '◎ goal complete'
1595
+ : data.operation === 'pause'
1596
+ ? '◎ goal paused'
1597
+ : data.operation === 'resume'
1598
+ ? '◎ goal resumed'
1599
+ : data.operation === 'block'
1600
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1601
+ : undefined
1602
+ acc.goal = goal
1603
+ if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1604
+ return true
1605
+ }
1606
+ case 'session/title':
1607
+ acc.title = event.data.title
1608
+ return true
1609
+ case 'compaction/summary':
1610
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1611
+ const oldest = acc.compactionTokens.keys().next().value
1612
+ if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1613
+ }
1614
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1615
+ return true
1616
+ case 'compaction/prune':
1617
+ acc.lastPruneTokens = event.data.shadowedTokenCount
1618
+ return true
1619
+ case 'compaction/end': {
1620
+ const ok = event.data.error === undefined
1621
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1622
+ acc.compactionTokens.delete(event.data.compactionId)
1623
+ appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1624
+ return true
1625
+ }
1626
+ case 'request/context':
1627
+ acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1628
+ return true
1629
+ case 'request/header': {
1630
+ const config = event.data.header.config
1631
+ acc.model = `${config.provider}/${config.model}`
1632
+ acc.stats = {
1633
+ ...acc.stats,
1634
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1635
+ }
1636
+ return true
1637
+ }
1638
+ case 'plan/mode':
1639
+ acc.plan = event.data.active
1640
+ return true
1641
+ case 'permission/preset':
1642
+ acc.permission = event.data.preset
1643
+ return true
1644
+ case 'command/run': {
1645
+ const data = event.data
1646
+ appendReplayEntry(acc, {
1647
+ kind: 'command',
1648
+ commandId: data.commandId,
1649
+ name: data.name,
1650
+ args: data.args ?? '',
1651
+ state: 'running',
1652
+ summary: '',
1653
+ })
1654
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1655
+ return true
1656
+ }
1657
+ case 'command/done': {
1658
+ const data = event.data
1659
+ const update = (candidate: CommandEntry): CommandEntry => ({
1660
+ ...candidate,
1661
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1662
+ summary: boundContextSummary(data.text ?? ''),
1663
+ })
1664
+ updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1665
+ return true
1666
+ }
1667
+ default:
1668
+ return false
1669
+ }
1670
+ }
1671
+
1672
+ /**
1673
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
1674
+ * retired tombstones. The anchors maps are handed through as-is (their
1675
+ * content is identical to a sequential fold's).
1676
+ *
1677
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1678
+ */
1679
+ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1680
+ return materializeReplayView(acc, false)
1681
+ }
1682
+
1683
+ /**
1684
+ * Materialize the accumulated fold as a fresh immutable snapshot for the
1685
+ * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1686
+ * hands the accumulator's own arrays through because the accumulator is
1687
+ * discarded — every array a renderer can hold is copied here, so later
1688
+ * folds never mutate a snapshot already handed out. Same fields, same
1689
+ * tombstone compaction.
1690
+ *
1691
+ * @internal Live-store path; `projectEvents` is the public entry.
1692
+ */
1693
+ export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1694
+ return materializeReplayView(acc, true)
1695
+ }
1696
+
1697
+ /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1698
+ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1699
+ const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1700
+ ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1701
+ : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1702
+ if (acc.removedCount > 0) acc.ops += acc.entries.length
1703
+ return {
1704
+ entries,
1705
+ streaming: acc.streaming,
1706
+ streamingReasoning: acc.streamingReasoning,
1707
+ todos: acc.todos,
1708
+ toolCallOrdinal: acc.toolCallOrdinal,
1709
+ busy: acc.busy,
1710
+ busySince: acc.busySince,
1711
+ model: acc.model,
1712
+ plan: acc.plan,
1713
+ permission: acc.permission,
1714
+ title: acc.title,
1715
+ systemPrompt: acc.systemPrompt,
1716
+ sandbox: acc.sandbox,
1717
+ goal: acc.goal,
1718
+ pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1719
+ stats: acc.stats,
1720
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1721
+ // folding its live containers, and no consumer may observe that.
1722
+ anchors: {
1723
+ stepStart: new Map(acc.stepStart),
1724
+ toolStart: new Map(acc.toolStart),
1725
+ firstChunkAt: new Map(acc.firstChunkAt),
1726
+ compactionTokens: new Map(acc.compactionTokens),
1727
+ lastPruneTokens: acc.lastPruneTokens,
1728
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1729
+ turnSteps: new Map(acc.turnSteps),
1730
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1731
+ systemNodes: new Map(acc.systemNodes),
1732
+ },
1733
+ }
1734
+ }
1735
+
1736
+ /**
1737
+ * Fold a replayed event history into one view.
1738
+ *
1739
+ * Folding is near-linear in the log size: the mutable replay accumulator
1740
+ * appends in place and resolves id-keyed updates through index maps, so a
1741
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
1742
+ * naive sequential fold would incur. The result is identical to folding
1743
+ * {@link projectEvent} per event in order.
1744
+ * @param events - events in `seq` order.
1745
+ * @returns the folded view.
1746
+ */
1747
+ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
1748
+ const acc = createReplayAccumulator()
1749
+ for (const event of events) replayProjectEvent(acc, event)
1750
+ return finishReplay(acc)
1751
+ }
1752
+
1753
+ /**
1754
+ * Fold one process-local assistant-stream chunk frame (session-log v2+ keeps
1755
+ * durable logs settlement-only; live typing rides the `agent/assistant-stream`
1756
+ * agent event). Same first-token anchoring the durable `assistant/chunk` event
1757
+ * used to carry: the first non-empty delta anchors the TTFT and empty
1758
+ * keep-alive deltas do not count. The caller maps the frame's attempt to the
1759
+ * `turn:step` key (the start frame owns turn/step; chunk frames do not).
1760
+ * @param acc - the live replay accumulator.
1761
+ * @param key - the `turn:step` key the attempt's start frame declared.
1762
+ * @param time - the frame's safe-integer timestamp.
1763
+ * @param chunk - the model chunk the frame carries.
1764
+ * @returns whether the accumulator changed (the store stays silent otherwise).
1765
+ */
1766
+ export function applyAssistantStreamChunk(acc: ReplayAccumulator, key: string, time: number, chunk: StreamChunk): boolean {
1767
+ // First-token latency uses the kernel's isTokenDelta rule (a non-empty
1768
+ // text, reasoning, or tool-call fragment counts; block/usage/finish chunks
1769
+ // do not), so live frames and replayed embedded streams time the same
1770
+ // token.
1771
+ if (isTokenDelta(chunk) && !acc.firstChunkAt.has(key)) {
1772
+ acc.firstChunkAt.set(key, time)
1773
+ const started = acc.stepStart.get(key)
1774
+ if (started !== undefined) {
1775
+ acc.stats = {
1776
+ ...acc.stats,
1777
+ ttftMs: acc.stats.ttftMs + Math.max(0, time - started),
1778
+ ttftSteps: acc.stats.ttftSteps + 1,
1779
+ }
1780
+ }
1781
+ }
1782
+ if (chunk.type === 'text-delta') {
1783
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1784
+ return true
1785
+ }
1786
+ if (chunk.type === 'reasoning-delta') {
1787
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1788
+ return true
1789
+ }
1790
+ return false
1791
+ }
1792
+
1793
+ /**
1794
+ * Drop the live streaming tails without a settlement (an `agent/assistant-stream`
1795
+ * end frame with an `abandoned` outcome, or a session switch). The next start
1796
+ * frame rebuilds from scratch.
1797
+ * @param acc - the live replay accumulator.
1798
+ * @returns whether any tail text was discarded.
1799
+ */
1800
+ export function clearAssistantStream(acc: ReplayAccumulator): boolean {
1801
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== ''
1802
+ acc.streaming = ''
1803
+ acc.streamingReasoning = ''
1804
+ return streamed
1805
+ }
1806
+
1807
+ /**
1808
+ * The append-only flush boundary for a transcript view: the count of entries
1809
+ * no later event can remove. Entries at or beyond this index are mutable and
1810
+ * must stay in the live tree.
1811
+ *
1812
+ * `pending` rows are excluded even though they are not a running tool/retry:
1813
+ * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1814
+ * `user/message` retirement), and an append-only `<Static>` flush cannot
1815
+ * erase a row that vanishes from the view — the retired row would ghost on
1816
+ * screen until the next source-backed replay. Running commands join the
1817
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
1818
+ * row's state/summary, so a flushed row would keep its stale running mark
1819
+ * until a resize-triggered replay. Everything else (including a completed
1820
+ * tail) is final: later events only APPEND new rows.
1821
+ * @param entries - the view's transcript entries in order.
1822
+ * @returns the count of entries safe to flush (0 for an empty transcript).
1823
+ */
1824
+ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1825
+ for (let index = 0; index < entries.length; index++) {
1826
+ const entry = entries[index]
1827
+ if (entry.kind === 'pending') return index
1828
+ if (entry.kind === 'tool' && entry.state === 'running') return index
1829
+ if (entry.kind === 'retry' && entry.state === 'running') return index
1830
+ if (entry.kind === 'command' && entry.state === 'running') return index
1831
+ }
1832
+ return entries.length
1833
+ }