File size: 28,538 Bytes
5710dd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
import {
  buildContextCompactionShape,
} from '@moonshot-ai/agent-core-v2/agent/contextMemory/compactionHandoff';
import {
  computeUndoCut,
  isFullyUndoable,
  readContextCompactionShapeInput,
} from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextOps';
import { createLoopEventFold } from '@moonshot-ai/agent-core-v2/agent/contextMemory/loopEventFold';
import { renderToolResultForModel } from '@moonshot-ai/agent-core-v2/agent/contextMemory/toolResultRender';
import type {
  ContentPart,
  ContextMessage,
  PermissionMode,
  TokenUsage,
  WireEntry,
} from './agent-record-types';

export interface ProjectedMessage {
  lineNo: number;
  time?: number;
  source: 'append_message' | 'compaction_summary' | 'undo' | 'clear';
  message: ContextMessage;
  toolStepUuids: string[];
  /** Set only when source === 'undo'. */
  undo?: { count: number; removedMessageCount: number };
  /** Set only on the summary bubble of source === 'compaction_summary'.
   *  `tokensBefore`/`tokensAfter` are absent on legacy payload variants. */
  compaction?: { compactedCount: number; tokensBefore?: number; tokensAfter?: number };
}

export interface UsageTotals {
  byScope: { session: TokenUsage; turn: TokenUsage };
  byModel: Record<string, TokenUsage>;
}

export interface ConfigSnapshot {
  cwd?: string;
  modelAlias?: string;
  profileName?: string;
  thinkingEffort?: string;
  systemPrompt?: string;
}

export interface GoalSnapshot {
  goalId: string;
  objective: string;
  completionCriterion?: string;
  status?: string;
  actor?: string;
  reason?: string;
  tokensUsed?: number;
  turnsUsed?: number;
  wallClockMs?: number;
}

export interface ContextProjection {
  messages: ProjectedMessage[];
  usage: UsageTotals;
  /** Absolute current context-window fill, mirroring the engine's token
   *  counting state. Updated from the latest step.end.usage and the
   *  token_counting.* records, and also reset on the lifecycle events the
   *  engine touches: context.clear β†’ 0, context.apply_compaction β†’
   *  tokensAfter. Distinct from the cumulative `usage` totals. */
  contextTokens: number;
  config: ConfigSnapshot;
  permission: { mode: PermissionMode | null };
  planMode: { active: boolean; id?: string };
  goal: GoalSnapshot | null;
  swarm: { active: boolean; trigger?: string };
}

const ZERO: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 };

/** Build a conversation timeline + derived state from a sequence of
 *  wire entries. The reconstruction mirrors the engine's own loop-event
 *  fold logic, so:
 *
 *  - `context.append_message` records become messages as-is (the
 *    user / tool messages and any explicit assistant injections).
 *  - `step.begin` settles a preceding attempt and opens a fresh assistant;
 *    later `content.part` and `tool.call` events on the same step grow that
 *    message. A normal `step.end` seals it (or drops it when vacuous), while
 *    interrupted/error steps stay partial until the next attempt.
 *  - pending tool calls defer appended messages; matching `tool.result`
 *    events close them, and an attempt that settles first gets synthetic
 *    interrupted results, exactly like engine replay.
 *
 *  Without this loop-event reconstruction the timeline would only
 *  show user prompts β€” the engine does not emit a synthetic
 *  `context.append_message` for assistant turns.
 *
 *  `mode` selects between two views of the four destructive lifecycle
 *  events (compaction / undo / clear / micro-compaction):
 *
 *  - `'model'` (default): faithfully mirrors what the model currently
 *    sees β€” compaction drops the compacted prefix, undo splices removed
 *    messages out, clear empties the list, micro-compaction blanks old
 *    tool results. All existing behaviour.
 *  - `'full'`: full reconstructed history for debugging β€” the same four
 *    events insert an INLINE MARKER but do NOT mutate/drop the message
 *    list, so messages compacted/undone/cleared away stay visible and
 *    micro-compacted tool results keep their original content.
 *
 *  Everything else (append_message, loop events, goal/swarm/permission/
 *  plan/config/usage/contextTokens derived state) is identical in both
 *  modes β€” `mode` only affects the `messages` array and which markers
 *  appear. */
export function projectContext(
  entries: ReadonlyArray<WireEntry>,
  mode: 'model' | 'full' = 'model',
): ContextProjection {
  let messages: ProjectedMessage[] = [];
  let modelMessages: ProjectedMessage[] = [];
  const usage: UsageTotals = {
    byScope: { session: { ...ZERO }, turn: { ...ZERO } },
    byModel: {},
  };
  const config: ConfigSnapshot = {};
  let permissionMode: PermissionMode | null = null;
  let planActive = false;
  let planId: string | undefined;
  let contextTokens = 0;
  let goal: GoalSnapshot | null = null;
  let swarm: { active: boolean; trigger?: string } = { active: false };
  let microCutoff = 0;
  let currentEntry: WireEntry | undefined;
  let openMessage: ProjectedMessage | undefined;
  let syntheticToolOrdinal = 0;
  const appendMessageEntries = new WeakMap<ContextMessage, ProjectedMessage>();

  const pushModelMessage = (message: ProjectedMessage): void => {
    modelMessages.push(message);
    messages.push(message);
  };

  const removeModelMessage = (message: ProjectedMessage): void => {
    const modelIndex = modelMessages.indexOf(message);
    if (modelIndex !== -1) modelMessages.splice(modelIndex, 1);
    const displayIndex = messages.indexOf(message);
    if (displayIndex !== -1) messages.splice(displayIndex, 1);
  };

  const currentLineNo = (): number => currentEntry?.lineNo ?? 0;

  const fold = createLoopEventFold({
    openAssistant: (time) => {
      const event = currentEntry?.data;
      const stepUuid =
        event?.type === 'context.append_loop_event' && event.event.type === 'step.begin'
          ? event.event.uuid
          : undefined;
      openMessage = {
        lineNo: currentLineNo(),
        time,
        source: 'append_message',
        message: { role: 'assistant', content: [], toolCalls: [], partial: true },
        toolStepUuids: stepUuid === undefined ? [] : [stepUuid],
      };
      pushModelMessage(openMessage);
    },
    appendOpenContent: (part) => {
      if (openMessage === undefined) return;
      openMessage.message = {
        ...openMessage.message,
        content: [...openMessage.message.content, part],
      };
    },
    appendOpenToolCall: (call) => {
      if (openMessage === undefined) return;
      openMessage.message = {
        ...openMessage.message,
        toolCalls: [...openMessage.message.toolCalls, call],
      };
    },
    dropOpenAssistant: () => {
      if (openMessage === undefined) return;
      removeModelMessage(openMessage);
      openMessage = undefined;
    },
    sealOpenAssistant: () => {
      if (openMessage === undefined) return;
      openMessage.message = { ...openMessage.message, partial: undefined };
      openMessage = undefined;
    },
    pushToolMessage: (message, time) => {
      const event = currentEntry?.data;
      const directResult =
        event?.type === 'context.append_loop_event' && event.event.type === 'tool.result';
      const lineNo = directResult
        ? currentLineNo()
        : currentLineNo() - 0.25 - syntheticToolOrdinal++ / 1000;
      pushModelMessage({
        lineNo,
        time,
        source: 'append_message',
        message: modelFacingMessage(message),
        toolStepUuids: [],
      });
    },
    pushMessage: (message, time) => {
      const projected = appendMessageEntries.get(message) ?? {
        lineNo: currentLineNo(),
        time,
        source: 'append_message' as const,
        message,
        toolStepUuids: [],
      };
      projected.message = modelFacingMessage(message);
      pushModelMessage(projected);
    },
  });

  const resetFold = (): void => {
    fold.reset();
    openMessage = undefined;
  };

  for (const entry of entries) {
    currentEntry = entry;
    syntheticToolOrdinal = 0;
    const rec = entry.data;
    switch (rec.type) {
      case 'context.append_message': {
        const message = normalizeLegacyOrigin(rec.message);
        appendMessageEntries.set(message, {
          lineNo: entry.lineNo,
          time: rec.time,
          source: 'append_message',
          message,
          toolStepUuids: [],
        });
        fold.appendMessage(message, rec.time);
        break;
      }
      case 'context.append_loop_event': {
        const ev = rec.event;
        fold.loopEvent(ev, rec.time);
        if (ev.type === 'step.end') {
          // Absolute context-window fill, mirroring the engine's token
          // counting state: the latest step.end usage REPLACES the
          // snapshot (it is not cumulative β€” see Task P1.7 note on byScope).
          // A zero-usage step.end (e.g. a content-filtered response) is the one
          // exception the engine makes β€” it keeps the prior count instead of
          // resetting to 0 β€” so guard against a false drop here too.
          if ('usage' in ev && ev.usage !== undefined) {
            const fill =
              ev.usage.inputCacheRead +
              ev.usage.inputCacheCreation +
              ev.usage.inputOther +
              ev.usage.output;
            if (fill > 0) contextTokens = fill;
          }
        }
        break;
      }
      case 'context.update_token_count':
        contextTokens = rec.tokenCount;
        break;
      case 'context.clear':
        resetFold();
        modelMessages = [];
        if (mode === 'model') {
          messages = [];
          // Mirror the engine's clear() β†’ legacy micro-compaction cutoff
          // reset (β†’ 0):
          // the message indices are wiped, so any prior cutoff is meaningless.
          microCutoff = 0;
        } else {
          // Full history: keep all preceding messages, just
          // append a synthetic 'clear' marker inline. The original tool results
          // stay un-blanked, so the cutoff is not applied (the end-of-loop
          // blanking pass is gated on model mode).
          messages.push({
            lineNo: entry.lineNo,
            time: rec.time,
            source: 'clear',
            // Synthetic marker: never rendered as a bubble (the web dispatches on
            // `source === 'clear'`). `role: 'assistant'` keeps it out of any
            // role-counting / tool-blanking path.
            message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage,
            toolStepUuids: [],
          });
        }
        // Mirror the engine's clear() β†’ token count = 0: the context-window
        // fill is wiped. Derived state, so it is mode-INDEPENDENT (applied for
        // both modes).
        contextTokens = 0;
        break;
      case 'context.apply_compaction': {
        let compactionInput: ReturnType<typeof readContextCompactionShapeInput>;
        try {
          compactionInput = readContextCompactionShapeInput(rec);
        } catch {
          break;
        }
        if (mode === 'full' && rec.keptUserMessageCount !== undefined) {
          fold.settle(rec.time);
        }
        const historyEntries = [...modelMessages];
        resetFold();
        // Mirror the engine's applyCompaction
        // (`packages/agent-core-v2/src/agent/contextMemory/`): the live history
        // becomes the kept real user messages (verbatim, within a token budget
        // β€” the oldest head plus the most recent tail, separated by an elision
        // marker when the pool overflowed) followed by a single user-role
        // summary tagged `origin.kind = 'compaction_summary'`. Assistant
        // messages, tool calls, and tool results are dropped. The selection
        // rules come from the same `buildContextCompactionShape` helper the
        // engine uses during replay, so both views stay in sync.
        //
        // The v2 payload is a union of three variants: current records carry
        // `summary` as a string (with `contextSummary` holding the
        // model-facing variant when media degraded); a legacy variant carries
        // the summary as a ContextMessage plus `count` instead of
        // `compactedCount`. `tokensBefore`/`tokensAfter` are optional in all
        // variants. Normalize before projecting.
        const rawSummary = rec.summary;
        const contextSummary = 'contextSummary' in rec ? rec.contextSummary : undefined;
        const summaryText =
          typeof rawSummary === 'string'
            ? rawSummary
            : rawSummary !== undefined
              ? contextMessageText(rawSummary)
              : (contextSummary ?? '');
        const shape = buildContextCompactionShape(
          historyEntries.map((message) => message.message),
          compactionInput,
        );
        const compactedCount = shape.compactedCount;
        const summaryBubble: ProjectedMessage = {
          lineNo: entry.lineNo,
          time: rec.time,
          source: 'compaction_summary',
          message: {
            role: 'user',
            content: [{ type: 'text', text: summaryText }],
            toolCalls: [],
            origin: { kind: 'compaction_summary' },
          } as ContextMessage,
          toolStepUuids: [],
          compaction: {
            compactedCount,
            tokensBefore: rec.tokensBefore,
            tokensAfter: shape.tokensAfter,
          },
        };
        const legacyTail = rec.legacyTail === true || rec.keptUserMessageCount === undefined;
        const summaryIndex = legacyTail
          ? 0
          : shape.messages.findIndex((message) => message.origin?.kind === 'compaction_summary');
        const modelSummaryBubble: ProjectedMessage = {
          ...summaryBubble,
          message: modelFacingMessage(shape.messages[summaryIndex] ?? summaryBubble.message),
        };
        const available = new Set(historyEntries);
        let syntheticOrdinal = 0;
        modelMessages = shape.messages.map((message, index) => {
          if (index === summaryIndex) return modelSummaryBubble;
          const original = historyEntries.find(
            (candidate) => available.has(candidate) && candidate.message === message,
          );
          if (original !== undefined) {
            available.delete(original);
            return original;
          }
          syntheticOrdinal += 1;
          return {
            lineNo: entry.lineNo - 0.5 - syntheticOrdinal / 1000,
            time: rec.time,
            source: 'append_message',
            message: modelFacingMessage(message),
            toolStepUuids: [],
          };
        });
        if (mode === 'model') {
          messages = [...modelMessages];
        } else {
          // Full history: keep ALL preceding messages, just append the summary
          // marker inline so the compacted prefix stays visible.
          messages.push(summaryBubble);
        }
        // Mirror the engine's applyCompaction() β†’ legacy micro-compaction
        // cutoff reset (β†’ 0): the message list is rebuilt, so the old
        // index-based cutoff no longer points at the same messages. (In full
        // mode the blanking pass does not run, so this is a no-op there.)
        microCutoff = 0;
        // `buildContextCompactionShape` also derives the post-compaction token
        // count for legacy records that omit `tokensAfter`.
        contextTokens = shape.tokensAfter;
        break;
      }
      case 'usage.record': {
        // byScope keeps per-scope cumulative spend. This is NOT the live context-window
        // fill β€” that is `contextTokens` (latest step.end.usage). The web TokenBar shows
        // contextTokens; byScope/byModel are for the cumulative breakdown only.
        const scope = (rec.usageScope ?? 'session') as 'session' | 'turn';
        addUsage(usage.byScope[scope], rec.usage);
        usage.byModel[rec.model] ??= { ...ZERO };
        addUsage(usage.byModel[rec.model]!, rec.usage);
        break;
      }
      case 'config.update': {
        // v2 dropped top-level `cwd` (it lives in `environmentDisclosure`)
        // and persists `thinkingLevel` on some records instead of
        // `thinkingEffort`; accept both spellings.
        if (rec.environmentDisclosure !== undefined)
          config.cwd = rec.environmentDisclosure.cwd;
        if (rec.modelAlias !== undefined) config.modelAlias = rec.modelAlias;
        if (rec.profileName !== undefined) config.profileName = rec.profileName;
        const effort = rec.thinkingEffort ?? rec.thinkingLevel;
        if (effort !== undefined) config.thinkingEffort = effort;
        if (rec.systemPrompt !== undefined) config.systemPrompt = rec.systemPrompt;
        break;
      }
      case 'profile.bind': {
        // v2 writes most initial config state on `profile.bind` rather than
        // `config.update` (which now carries only later updates).
        if (rec.environmentDisclosure !== undefined)
          config.cwd = rec.environmentDisclosure.cwd;
        if (rec.modelAlias !== undefined) config.modelAlias = rec.modelAlias;
        if (rec.profileName !== undefined) config.profileName = rec.profileName;
        config.thinkingEffort = rec.thinkingEffort;
        config.systemPrompt = rec.systemPrompt;
        break;
      }
      case 'permission.set_mode':
        permissionMode = rec.mode;
        break;
      case 'plan_mode.enter':
        planActive = true; planId = rec.id; break;
      case 'plan_mode.cancel':
      case 'plan_mode.exit':
        planActive = false; planId = undefined; break;
      case 'context.undo': {
        // Mirror the engine's `undo`: locate the requested user anchor while
        // skipping injections, stop at a compaction summary, include an
        // immediately preceding prompt-owned injection in the cut, then remove
        // the entire suffix from that cut. The UI adds a marker afterwards.
        //
        // `computeUndoCut` is the engine's single source of truth for that
        // skip/stop walk; only the visible removal is gated on `'model'` mode.
        const cut = computeUndoCut(
          modelMessages.map((message) => message.message),
          rec.count,
        );
        const applied = isFullyUndoable(cut, rec.count);
        const removedMessageCount = applied ? modelMessages.length - cut.cutIndex : 0;
        if (applied) {
          const firstRemoved = modelMessages[cut.cutIndex];
          modelMessages = modelMessages.slice(0, cut.cutIndex);
          resetFold();
          if (mode === 'model') {
            const displayCutoff = firstRemoved === undefined ? -1 : messages.indexOf(firstRemoved);
            messages = displayCutoff === -1 ? [...modelMessages] : messages.slice(0, displayCutoff);
          }
        }
        if (mode === 'model') {
          // Mirror the engine's undo() β†’ legacy micro-compaction cutoff reset
          // (to the post-undo history length):
          // clamp the cutoff to the post-undo HISTORY-entry count so a later append
          // does not get blanked by a now-too-large stale cutoff. Count only history
          // entries (`isHistoryEntry`) β€” `messages.length` would include any surviving
          // synthetic undo/clear marker, which the engine's `_history.length` does
          // NOT, so an array-length clamp could be too high by the marker count.
          // (Clamp before pushing the undo marker, which is a non-tool pseudo-message
          // and unaffected by blanking regardless.) With no markers, historyCount ===
          // messages.length, so this is a no-op then.
          microCutoff = Math.min(microCutoff, modelMessages.length);
        }
        // In 'full' mode: do NOT remove the visible messages; only push the undo
        // marker. `modelMessages` still advances exactly like engine state so a
        // later undo/compaction is computed from the right live history.
        messages.push({
          lineNo: entry.lineNo,
          time: rec.time,
          source: 'undo',
          // Synthetic message: never rendered. The web dispatches on
          // `source === 'undo'`; this only satisfies ProjectedMessage.
          // `role: 'assistant'` is deliberate so this marker can never match the
          // `role: 'tool'` micro-compaction blanking gate β€” keep it non-tool if
          // you ever change the placeholder.
          message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage,
          toolStepUuids: [],
          undo: { count: rec.count, removedMessageCount },
        });
        break;
      }
      case 'micro_compaction.apply':
        // Track the latest cutoff; the actual content blanking is applied
        // after the loop (mirrors the engine's legacy MicroCompaction.compact,
        // which runs over the full history at projection time).
        microCutoff = rec.cutoff;
        break;
      case 'goal.create':
        goal = {
          goalId: rec.goalId,
          objective: rec.objective,
          completionCriterion: rec.completionCriterion,
        };
        break;
      case 'goal.update':
        if (goal !== null) {
          const prev: GoalSnapshot = goal;
          goal = {
            ...prev,
            status: rec.status ?? prev.status,
            actor: rec.actor ?? prev.actor,
            reason: rec.reason ?? prev.reason,
            tokensUsed: rec.tokensUsed ?? prev.tokensUsed,
            turnsUsed: rec.turnsUsed ?? prev.turnsUsed,
            wallClockMs: rec.wallClockMs ?? prev.wallClockMs,
          };
        }
        break;
      case 'goal.clear':
        goal = null;
        break;
      case 'swarm_mode.enter':
        swarm = { active: true, trigger: rec.trigger };
        break;
      case 'swarm_mode.exit':
        swarm = { active: false };
        break;
      case 'tower_mode.enter':
      case 'tower_mode.exit':
        break;
      case 'token_counting.measured':
      case 'token_counting.truncated':
      case 'token_counting.rebased':
      case 'token_counting.turn_recorded':
        // v2's replacement for `context.update_token_count`: every
        // token_counting record carries the agent's current context-window
        // fill (`tokens`) β€” the tokenCounting model sets its running count
        // from each of them, and so do we.
        contextTokens = rec.tokens;
        break;
      // Kinds that don't affect the projected timeline / derived state,
      // including the observability records (request trace β€” `llm.*`,
      // `mcp.tools_discovered`) and v2's lifecycle/task bookkeeping, which
      // are never part of context state:
      case 'metadata':
      case 'forked':
      case 'turn.prompt':
      case 'turn.steer':
      case 'turn.cancel':
      case 'turn.ended':
      case 'turn.step.interrupted':
      case 'turn.step.retrying':
      case 'prompt.accepted':
      case 'prompt.aborted':
      case 'prompt.completed':
      case 'prompt.steered':
      case 'interaction.request':
      case 'interaction.resolved':
      case 'task.started':
      case 'task.terminated':
      case 'task.waitDelivered':
      case 'cron.add':
      case 'cron.cursor':
      case 'cron.delete':
      case 'plan.revision':
      case 'plugin.session_start':
      case 'runtime.set_binding':
      case 'staleGuard.recorded':
      case 'staleGuard.cleared':
      case 'interruptionReminder.recorded':
      case 'permission.record_approval_result':
      case 'full_compaction.begin':
      case 'full_compaction.cancel':
      case 'full_compaction.complete':
      case 'tools.register_user_tool':
      case 'tools.unregister_user_tool':
      case 'tools.set_active_tools':
      case 'tools.update_store':
      case 'tools.reset_active_tools':
      case 'llm.tools_snapshot':
      case 'llm.request':
      case 'mcp.tools_discovered':
      case 'file_history.checkpoint':
      case 'file_history.tracked':
        break;
      default: {
        const _exhaustive: never = rec;
        void _exhaustive;
        break;
      }
    }
  }

  // Micro-compaction blanking (mirrors the engine's legacy
  // MicroCompaction.compact): blank any message whose HISTORY index < cutoff
  // that is a `role: 'tool'` result with a defined toolCallId and content
  // large enough (β‰₯ the min-content gate), replacing its content with the
  // truncation marker. The cutoff is an engine `_history` index, which never
  // includes our synthetic 'undo'/'clear' markers, so we count only history
  // entries (`isHistoryEntry`)
  // β€” array indices would be offset by any preceding marker. This rewrite is the
  // model's-eye view, so it runs ONLY in 'model' mode β€” in 'full' mode the
  // original tool results are shown un-blanked.
  if (mode === 'model' && microCutoff > 0) {
    let historyIndex = 0;
    for (const pm of messages) {
      if (!isHistoryEntry(pm)) continue;
      if (historyIndex >= microCutoff) break;
      historyIndex++;
      const m = pm.message;
      if (
        m.role === 'tool' &&
        m.toolCallId !== undefined &&
        estimateContentTokens(m.content) >= MICRO_MIN_CONTENT_TOKENS
      ) {
        pm.message = { ...m, content: [{ type: 'text', text: MICRO_TRUNCATED_MARKER }] };
      }
    }
  }

  return {
    messages,
    usage,
    contextTokens,
    config,
    permission: { mode: permissionMode },
    planMode: { active: planActive, id: planId },
    goal,
    swarm,
  };
}

function addUsage(into: TokenUsage, src: TokenUsage): void {
  (into as any).inputOther += src.inputOther;
  (into as any).output += src.output;
  (into as any).inputCacheRead += src.inputCacheRead;
  (into as any).inputCacheCreation += src.inputCacheCreation;
}

const MICRO_TRUNCATED_MARKER = '[Old tool result content cleared]';
const MICRO_MIN_CONTENT_TOKENS = 100;

/** Replicates the engine's per-char token weighting exactly, over the same
 *  `text` + `think` parts its gate counts. The engine
 *  (`packages/agent-core-v2/src/llm-adapter/contract/tokens.ts`) sums per-part
 *  estimates, each
 *  `estimateTokens(s) = Math.ceil(asciiCount / 4) + nonAsciiCount` (ASCII ~4
 *  chars/token, every non-ASCII/CJK code point a full token); other part types
 *  contribute 0. Matching it ensures Chinese-heavy tool results blank at the
 *  same gate as the agent. */
function estimateTokens(text: string): number {
  let asciiCount = 0;
  let nonAsciiCount = 0;
  for (const char of text) {
    if (char.codePointAt(0)! <= 127) {
      asciiCount++;
    } else {
      nonAsciiCount++;
    }
  }
  return Math.ceil(asciiCount / 4) + nonAsciiCount;
}

function estimateContentTokens(content: readonly ContentPart[]): number {
  let total = 0;
  for (const p of content) {
    if (p.type === 'text') total += estimateTokens(p.text);
    else if (p.type === 'think') total += estimateTokens(p.think);
  }
  return total;
}

/** True for messages that correspond to a real `_history` entry β€”
 *  i.e. `append_message` and `compaction_summary` (the summary IS in `_history`).
 *  The synthetic UI-only markers (`undo` / `clear`) are NOT in `_history`, so
 *  index-based operations that mirror the engine (compaction slice, micro-
 *  compaction cutoff) must skip them to stay aligned with engine indices. */
function isHistoryEntry(pm: ProjectedMessage): boolean {
  return pm.source !== 'undo' && pm.source !== 'clear';
}

function modelFacingMessage(message: ContextMessage): ContextMessage {
  if (message.role !== 'tool') return message;
  return {
    ...message,
    content: renderToolResultForModel({
      output: message.content,
      isError: message.isError,
      note: message.note,
    }),
    note: undefined,
  };
}

/** v1 wires tag background-task prompts `origin.kind === 'background_task'`;
 *  v2 renamed the kind to 'task' (same status literals). Normalize on ingest
 *  so the engine's undo helper and the web see one vocabulary. */
function normalizeLegacyOrigin(message: ContextMessage): ContextMessage {
  const origin = message.origin as { readonly kind: string } | undefined;
  if (origin?.kind !== 'background_task') return message;
  return { ...message, origin: { ...origin, kind: 'task' } as ContextMessage['origin'] };
}

/** Text rendering of a ContextMessage's content parts, used to surface the
 *  legacy `context.apply_compaction` variant whose summary is a message. */
function contextMessageText(message: ContextMessage): string {
  return message.content
    .filter((part) => part.type === 'text')
    .map((part) => part.text)
    .join('\n');
}