diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
index 8f9aa62e49..9232de3c69 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
-2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956
-2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5
+2026-06-14-session-persistence.md: cef11271f26c304ad484d7851801bc69d0c1dfda
+2026-06-14-session-persistence.zh.md: b6c2467888d0d348aa492c265542565563b75fab
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
index 62228bd2f5..cef11271f2 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md
@@ -29,7 +29,7 @@ Key durable, contested choices:
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
-Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
+Format versioning: the header carries a `version`; cold reads accept the current version or a complete static adjacent-version decoder path and reject future versions or missing steps. The format decoder owns historical header and event conversion, while the Coordinator owns operation-specific recovery after decoding ([Session log versioning](2026-08-10-session-log-version-mechanism.md)). The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise. Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
## Consequences
diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
index ebf004333c..b6c2467888 100644
--- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
@@ -29,7 +29,7 @@ Status: implemented
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
-格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
+格式版本控制:header 携带一个 `version`;冷读取接受当前版本或完整的静态相邻版本 decoder 路径,并拒绝未来版本或缺失步骤。Format decoder 负责历史 header 和 event 转换,Coordinator 只在解码后负责各操作自己的 recovery([Session log 版本机制](2026-08-10-session-log-version-mechanism.zh.md))。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
index 85793a0b5c..ee8c71110e 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 81108ceaf23405c8f2def9aaef88505d635808a3
-2026-08-10-session-log-version-mechanism.zh.md: cbb127420e2695853fdc2ad0bb98a7a0bf230b5b
+2026-08-10-session-log-version-mechanism.md: dfbe5c1926cf683a34ec6694f188f57c44b9ca10
+2026-08-10-session-log-version-mechanism.zh.md: 00d58757d3ea4bf1689a0847613557613d40ebf6
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
index 81108ceaf2..dfbe5c1926 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
@@ -14,13 +14,21 @@ Session logs must be upgradable after release, and the runtime that ships first
**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
-**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
+**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatMigration` classes; a missing migration refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set.
+
+**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each migration class carries static adjacent `from`/`to` versions. One fresh instance handles one decode attempt: `header()` runs once, `event()` maps each input record to exactly one lossless-JSON output with the same seq, and optional `finish()` validates accumulated state after EOF. Instance fields may retain header and earlier-event facts without sharing state across sessions, concurrent reads, or revision retries. Header-only reads stop after `header()` and never call `finish()`, so that method validates EOF state rather than releasing resources. Any version conversion reads the complete event stream and applies the requested suffix only after all migrations; an equal-version read retains backend suffix seek. The decoder validates each output header version and each migration's seq preservation, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain.
+
+**A future format bump adds one format-owned migration.** The change adds `format-migrations/vN-to-vN+1.ts`, exports its class from the static `SESSION_FORMAT_MIGRATIONS` array, and increments `SESSION_FORMAT_VERSION`. The migration owns every old header and event variant it accepts, its instance state, and explicit failure for malformed input. It cannot add, remove, reorder, or renumber events: durable references use seq as event identity. A format change that alters facts consumed by a projection increments that projection's `stateVersion`; unchanged projections retain their cache rows. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version migrations. This decoder maps the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to canonical `compaction/*` events while preserving the rest of each record.
+
+**Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing.
+
+**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision at the commit boundary. JSONL writes and fsyncs a sibling temporary artifact, rechecks the source revision immediately before the atomic replace, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX; like every other coordinator freshness check, the recheck adds no cross-process writer exclusion — JSONL assumes one live writer per session. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API.
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
## Consequences
-What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
+Format v0 carries direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema; and the static streaming migration decoder with an empty adjacent-version registry. `SESSION_FORMAT_VERSION` remains 0 until a real v0→v1 step lands. The decoder and backend replacement APIs therefore have direct tests without manufacturing a format bump. Writers do not yet set `ignorable` because no producer needs it. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers; the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header fields or decoding any event record, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
## Alternatives considered
@@ -28,3 +36,6 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path
- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.
+- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus one-at-a-time event transforms preserve retry semantics without imposing that allocation.
+- **Version-specific conversion in `PersistenceCoordinator`** — mixes format decoding with operation-specific crash recovery and duplicates behavior across inspect, suffix read, cold continuation, and live adoption. The shared decoder produces only current-format data; each consumer retains its own recovery intent.
+- **A mandatory permanent backup for every upgrade** — is not needed for atomicity and cannot promise the same physical representation across JSONL and SQLite. Backends may add recovery copies as a separate product policy without changing migrations.
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
index cbb127420e..00d58757d3 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
@@ -14,13 +14,21 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
-**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
+**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatMigration` 类组成完整链路,缺失任何 migration 都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。
+
+**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每个 migration class 用静态且相邻的 `from`/`to` 标识版本。每次 decode 都创建一个新实例:`header()` 调用一次;`event()` 把每条输入记录映射为一条 seq 相同、可无损表示为 JSON 的输出;可选的 `finish()` 在 EOF 后验证累计状态。实例字段可以保留 header 与之前事件的事实,而不会在 Session、并发读取或 revision retry 之间共享状态。只读 header 时在 `header()` 后结束,绝不调用 `finish()`,因此该方法用于验证 EOF 状态而不是释放资源。只要发生版本转换,就读取完整事件流,并在所有 migration 完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version 和每个 migration 是否保持 seq,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。
+
+**以后每次 format bump 只增加一个格式 migration。**改动新增 `format-migrations/vN-to-vN+1.ts`,把它的 class 导出到静态 `SESSION_FORMAT_MIGRATIONS` 数组,并递增 `SESSION_FORMAT_VERSION`。Migration 自己负责它接受的所有旧 header 和 event 变体、实例状态,以及对畸形输入的明确失败。它不能增加、删除、重排事件或重编号:持久引用以 seq 作为事件身份。如果格式变化影响了某个 projection 消费的事实,就递增该 projection 的 `stateVersion`;未受影响的 projection 保留 cache 记录。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本 migration 的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。
+
+**Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。
+
+**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交边界检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在原子替换路径前立即复核 source revision,然后原子替换(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录;与协调器的其他新鲜性检查一样,复核不提供跨进程写者排他——JSONL 假定每个 session 同时只有一个 live writer。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
## 影响
-v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
+Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 15)和 BFF 线上 schema 接受;以及使用空相邻版本注册表的静态流式 migration decoder。`SESSION_FORMAT_VERSION` 保持 0,直到真实 v0→v1 步骤合入。Decoder 和 backend replacement API 因此可以直接测试,不需要制造一次 format bump。写入侧目前不写 `ignorable`,因为还没有生产者需要它。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话;拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 字段、解码任何 event record 之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
## 曾考虑的替代方案
@@ -28,3 +36,6 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。
+- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加逐事件转换保留重试语义,又不强制这笔分配。
+- **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。
+- **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
index 303caf020b..671ca8f796 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: 0ad74546b9a54d5eadfc3e7991efa4e0d9b7bc77
-2026-07-23-web-assistant-markdown.zh.md: 9349847228b10c13ecead4374ad2fc9210f5e9f1
+2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b
+2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
index 0ad74546b9..d2b8e30d77 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
@@ -12,7 +12,7 @@ The Web conversation preserves assistant Markdown source through session events,
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
-`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
+`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism).
Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
index 9349847228..c1542d75fa 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
@@ -12,7 +12,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
-`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
+`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml
new file mode 100644
index 0000000000..2139112dec
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
+2026-08-20-web-streaming-fence-highlight.md: ccc961da1febba3611e3e558087b586c6f1f474b
+2026-08-20-web-streaming-fence-highlight.zh.md: e5d29545bf659ed572d052f1212d68d344d49b5a
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
new file mode 100644
index 0000000000..ccc961da1f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md
@@ -0,0 +1,37 @@
+# Agent Note: Streaming fences highlight incrementally
+
+Status: implemented
+
+English | [中文](2026-08-20-web-streaming-fence-highlight.zh.md)
+
+## Problem
+
+While a reply streamed, `MarkdownText` stripped the fence language before `CodeBlock` saw it, so code rendered as plain monospace with an empty language banner until the finalize swap recolored the whole reply at once ([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499)). The plain arm was a deliberate cost guard, recorded in the [assistant-markdown note](2026-07-23-web-assistant-markdown.md): shiki tokenizes a document from the top, so highlighting a growing fence naively re-tokenizes the whole fence on every chunk — quadratic in fence length over the stream, the same cost class the [incremental markdown parser](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) removes for block parsing. The fix has to deliver highlighting during streaming without reintroducing that cost, without transiently coloring under a wrong grammar while the info string is still mid-chunk, and without changing the settled render.
+
+## Decision
+
+Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged.
+
+- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`), and each update tokenizes newly completed text via `codeToTokensBase(…, { grammarState })` plus the still-growing last line. Per-chunk cost excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting — so the streaming spans and the settled `codeToHtml` swap render one identical span tree.
+- **`CodeBlock`** gains a `streaming` prop: it renders the session's spans as a `pre.shiki.css-variables` React tree with the same attributes shiki's HTML emits, holds the session and per-line elements in refs, and reuses a retained line's element identity so React leaves that line's DOM untouched. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back.
+- **`render.tsx`** passes `lang` and `context.streaming` to fences. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. The streaming CodeBlock instance survives every chunk because streaming render keys are source offsets. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
+
+The settle swap re-renders through `highlightToHtml`: same tokens, same span tree, so the swap is visually invisible and never touches the code content.
+
+## Testing
+
+Package tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; cache identity and reset/lazy paths; streaming/settled token-tree parity; DOM retention; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
+
+## Alternatives considered
+
+**Pass `lang` through and re-tokenize the whole fence per chunk.** One-line fix, but it reverses the recorded plain-arm rationale without addressing it: a long streaming fence pays quadratic tokenization over the stream, janking exactly on the replies where highlighting matters most.
+
+**Highlight only frozen (closed, settled-position) fences during streaming.** Bounded cost, but an unclosed fence pins the incremental parser's tail, so the actively growing fence — the one on screen — would stay plain until the reply finishes, failing the issue's "识别语言后即可增量高亮".
+
+**Move highlighting to a worker or async pass.** Rejected when shiki was adopted ([synchronous highlighting note](../process/2026-07-26-web-syntax-highlighting-shiki.md)); an async swap also reintroduces the plain→colored→plain flicker class this change must avoid.
+
+**Build the settled HTML string incrementally and keep `dangerouslySetInnerHTML`.** Exact settled parity for free, but React replaces the whole `innerHTML` per chunk, so the browser re-parses and rebuilds every line's DOM each time — O(fence) DOM churn that forfeits the token-level win the session provides.
+
+## Consequences
+
+Streaming code is readable as it arrives: tokens color as soon as the language is known, completed lines never re-tokenize or re-render, and the finalize swap is invisible for fences. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The still-growing last line re-tokenizes per chunk (bounded by one line), and a pathological single-line fence still degrades to full re-tokenization per chunk — the same degradation class the incremental block parser accepts for a single giant block.
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
new file mode 100644
index 0000000000..e5d29545bf
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 流式围栏代码增量高亮
+
+Status: implemented
+
+[English](2026-08-20-web-streaming-fence-highlight.md) | 中文
+
+## Problem
+
+回复流式输出期间,`MarkdownText` 在 `CodeBlock` 看到围栏语言之前就把它剥掉,代码因此以无高亮的等宽纯文本呈现、语言横幅为空,直到定稿切换一次性重新着色整个回复([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499))。纯文本臂是一道刻意的成本防线,记录于 [assistant-markdown 笔记](2026-07-23-web-assistant-markdown.zh.md):shiki 从文档顶部开始 tokenize,朴素地高亮一个增长中的围栏意味着每个分片都重新 tokenize 整个围栏——随流式过程对围栏长度呈平方级,与[增量 markdown 解析器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)为块解析消除的是同一类成本。修复必须在流式期间给出高亮,同时不重新引入该成本、不在 info string 尚在分片中途时以错误语法短暂着色、也不改变定稿渲染。
+
+## Decision
+
+流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`),每次更新通过 `codeToTokensBase(…, { grammarState })` tokenize 新完成的文本,外加仍在增长的最后一行。每分片成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致——因此流式 span 与定稿 `codeToHtml` 换入的 span 树完全一致。
+- **`CodeBlock`** 新增 `streaming` prop:把会话的 span 渲染为带有 shiki HTML 同款属性的 `pre.shiki.css-variables` React 树,用 ref 持有会话与逐行元素,并复用保留行的元素标识,让 React 完全不触碰该行的 DOM。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。
+- **`render.tsx`** 向围栏传递 `lang` 与 `context.streaming`。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 `
`。流式渲染 key 是源偏移,围栏的 CodeBlock 实例因此跨分片存活。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+
+定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+
+## Testing
+
+包测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,缓存标识与重置/懒加载路径,流式/定稿 token 树一致性,DOM 保留,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
+
+## Alternatives considered
+
+**直接透传 `lang`,每个分片重新 tokenize 整个围栏。** 一行改动,但在不回应的情况下推翻了已记录的纯文本臂理由:长流式围栏在整个流式过程付出平方级 tokenize 成本,恰恰在高亮最有价值的长代码回复上产生卡顿。
+
+**流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
+
+**把高亮移到 worker 或异步流程。** 采纳 shiki 时已否决([同步高亮笔记](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md));异步换入还会重新引入本变更必须避免的纯文本→彩色→纯文本闪烁类问题。
+
+**增量拼接定稿 HTML 字符串并继续使用 `dangerouslySetInnerHTML`。** 白得定稿一致性,但 React 每个分片都会整体替换 `innerHTML`,浏览器每次重新解析并重建所有行的 DOM——O(围栏) 的 DOM 翻搅,抵消了会话在 token 层的收益。
+
+## Consequences
+
+流式代码随到达即可读:语言一经识别 token 即着色,已完成行绝不重新 tokenize 或重渲染,定稿切换对围栏而言不可见。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。仍在增长的最后一行每分片重新 tokenize(以一行为界);病态的单行超长围栏仍退化为每分片完整重新 tokenize——与增量块解析器对单个巨型块接受的是同一退化类。
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
new file mode 100644
index 0000000000..7207de464b
--- /dev/null
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -0,0 +1,97 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Stream one TypeScript fence for" [disabled]
+ - img
+ - text: Standard mode
+ - button "Session log":
+ - text: Session log
+ - img
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
+- button "Copy":
+ - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+ - img
+ - img
+ - text: Context injection @deepseek-ai/dsh-system-prompt
+- text: ts
+- button "Copy"
+- code: "const first: number = 1 const second = \"two\" let tail"
+- status: Deep diving...
+- textbox "Message the agent"
+- button "Commands":
+ - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current streaming-fence-highlight-test/streaming-fence":
+ - text: streaming-fence-highlight-test/streaming-fence
+ - img
+- button "Stop generating"
+
+---
+
+{
+ "language": "ts",
+ "pre": {
+ "className": "shiki css-variables",
+ "style": "background-color: var(--shiki-background); color: var(--shiki-foreground);",
+ "tabIndex": "0"
+ },
+ "lines": [
+ [
+ {
+ "text": "const",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " first",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": ":",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " number",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": " =",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " 1",
+ "style": "color: var(--shiki-token-constant);"
+ }
+ ],
+ [
+ {
+ "text": "const",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " second",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": " =",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " \"two\"",
+ "style": "color: var(--shiki-token-string-expression);"
+ }
+ ],
+ [
+ {
+ "text": "let",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " tail",
+ "style": "color: var(--shiki-foreground);"
+ }
+ ]
+ ]
+}
diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts
new file mode 100644
index 0000000000..594d51c949
--- /dev/null
+++ b/apps/web/tests/streaming-fence-highlight.e2e.ts
@@ -0,0 +1,146 @@
+/** Keyless assembled-Web evidence for syntax highlighting during a streamed code fence. */
+
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-agent-default-model'
+import {
+ assertFixtureInventory,
+ captureStableAria,
+ compareOrRefreshGolden,
+ launchWebScaffold,
+ watchConsole,
+ webSnapshotMode,
+ type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/streaming-fence-highlight', import.meta.url))
+const MID_EXPECTED = fileURLToPath(new URL('./snapshots/streaming-fence-highlight/mid-stream.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const PROVIDER = 'streaming-fence-highlight-test'
+const MODEL = 'streaming-fence'
+const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
+const OPEN_REPLY = '```ts\nconst first: number = 1\nconst second = "two"\nlet tail'
+const REPLY = `${OPEN_REPLY}\n\`\`\``
+
+/** Deterministic model response held after the visible fence body arrives. */
+class StreamingFenceAdapter extends LlmAdapter {
+ private resolvePaused!: () => void
+ private resolveContinuation!: () => void
+ private continued = false
+ readonly paused = new Promise((resolve) => { this.resolvePaused = resolve })
+ private readonly continuation = new Promise((resolve) => { this.resolveContinuation = resolve })
+
+ continue(): void {
+ if (this.continued) return
+ this.continued = true
+ this.resolveContinuation()
+ }
+
+ override async *stream(options: GenerateOptions): AsyncIterable {
+ yield { type: 'block-start', index: 0, blockType: 'text' }
+ yield { type: 'text-delta', index: 0, text: OPEN_REPLY }
+ this.resolvePaused()
+ await this.continuation
+ if (options.signal?.aborted === true) throw options.signal.reason
+ yield { type: 'text-delta', index: 0, text: '\n```' }
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
+ yield { type: 'finish', reason: { kind: 'stop' } }
+ }
+}
+
+interface FenceTree {
+ language: string
+ pre: { className: string; style: string | null; tabIndex: string | null }
+ lines: { text: string; style: string | null }[][]
+}
+
+/** Read the stable, user-visible subset of one rendered code fence. */
+async function fenceTree(block: ReturnType): Promise {
+ return await block.evaluate((element) => {
+ const pre = element.querySelector('pre.shiki')
+ if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
+ return {
+ language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
+ pre: {
+ className: pre.className,
+ style: pre.style.cssText,
+ tabIndex: pre.getAttribute('tabindex'),
+ },
+ lines: [...pre.querySelectorAll('.line')].map(line =>
+ [...line.querySelectorAll('span')].map(span => ({
+ text: span.textContent ?? '',
+ style: span.style.cssText,
+ })),
+ ),
+ }
+ })
+}
+
+describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const adapter = new StreamingFenceAdapter()
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold()
+ scaffold.ctx.effect(
+ () => scaffold.ctx.llm.registerAdapter([PROVIDER], adapter),
+ 'streaming fence highlight adapter',
+ )
+ await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ adapter.continue()
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
+ const input = page.locator('textarea').first()
+ const settled = scaffold.whenTurnSettled(30_000)
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ await adapter.paused
+
+ const streaming = page.locator('[data-streaming="true"]')
+ await streaming.waitFor({ timeout: 10_000 })
+ const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
+ await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
+ const midTree = await fenceTree(block)
+ expect(midTree.language).toBe('ts')
+ expect(midTree.lines).toHaveLength(3)
+ expect(midTree.lines.flat().map(span => span.style)).toContain('color: var(--shiki-token-keyword);')
+
+ const aria = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(
+ MID_EXPECTED,
+ `${aria}\n\n---\n\n${JSON.stringify(midTree, null, 2)}`,
+ MODE,
+ )
+
+ adapter.continue()
+ await settled
+ await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+ const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
+ await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
+ expect(await fenceTree(settledBlock)).toEqual(midTree)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
+ }, 60_000)
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 386615f059..c14855169f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,6 +43,7 @@
"tests/models-settings.e2e.ts",
"tests/default-model.e2e.ts",
"tests/github-ready-review.e2e.ts",
+ "tests/streaming-fence-highlight.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/onboarding-usable-provider.e2e.ts",
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 1ca2c9ffa2..72c4c6a565 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: e4c43c305f22b2ab1713626c0a6a6bde6c4d8e1b
-config-catalog.zh.md: 6f8afcd8aca2e675a3bfa82ea0ec205d6046a1fb
+config-catalog.md: c4a148022d02a5adf12ede81241bd6a7bc7af5a5
+config-catalog.zh.md: 5fd1e79d8a717b50b752079f02dd459ed5297b34
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index e4c43c305f..c4a148022d 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1787,7 +1787,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:64`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 6f8afcd8ac..5fd1e79d8a 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1789,7 +1789,7 @@ export interface Config {
export type JsonlCompression = 'zstd' | 'none'
```
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:62`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:64`](../packages/session/session-persistence-jsonl/src/index.ts)
diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml
index f85e085da9..0bc2413aa3 100644
--- a/docs/subsystems/persistence.i18n.yaml
+++ b/docs/subsystems/persistence.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: 098f5798e5313ca97e90e67dce1d67177f003ca7
-persistence.zh.md: d6b3baf7cdb7f1735008e0c1da9740e0b756baff
+persistence.md: 5046be0f2ff65faa7fa71f41d8141399d55bfa96
+persistence.zh.md: 71bbca1121e5b8d1e9441d857a0d0989c9946d51
diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md
index 098f5798e5..5046be0f2f 100644
--- a/docs/subsystems/persistence.md
+++ b/docs/subsystems/persistence.md
@@ -51,8 +51,8 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
interface SessionHeader {
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
- * session is created. A persistence backend rejects any other version on load
- * (no migration — see the constant).
+ * session is created. Persistence refuses newer versions and older versions
+ * without a complete registered migration path.
*/
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
@@ -91,7 +91,27 @@ interface SessionHeader {
## Format refusal — logs a build cannot faithfully read
-A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
+A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it requires a complete registered adjacent-version migration path or names the missing step. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale lives in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
+
+## `SessionFormatMigration` — adjacent static format upgrades
+
+Each migration class declares one adjacent `from`/`to` pair and creates fresh state for one decode attempt. The decoder snapshots every header and event output as detached lossless JSON before the next migration receives it, preserves event sequence numbers, and calls optional EOF validation only after the complete event stream is consumed. The [package README](../../packages/session/session-persistence/README.md) owns the registration and version-bump procedure.
+
+```ts type-equiv
+/** Static identity and constructor for one adjacent-version migration. */
+interface SessionFormatMigration {
+ /** Input Session format version. */
+ readonly from: number
+ /** Output Session format version; must equal `from + 1`. */
+ readonly to: number
+ /**
+ * Create fresh state for one header decode and its optional complete event
+ * stream. Instances are never shared across sessions or decode attempts.
+ * @returns a single-use migration instance.
+ */
+ new(): SessionFormatMigrationInstance
+}
+```
## `CreateSessionOptions` — seeding and metadata
diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md
index d6b3baf7cd..71bbca1121 100644
--- a/docs/subsystems/persistence.zh.md
+++ b/docs/subsystems/persistence.zh.md
@@ -51,8 +51,8 @@ interface SessionLocation {
interface SessionHeader {
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
- * session is created. A persistence backend rejects any other version on load
- * (no migration — see the constant).
+ * session is created. Persistence refuses newer versions and older versions
+ * without a complete registered migration path.
*/
readonly version: number
/** The session's id (mirrors the {@link Session}'s id). */
@@ -91,7 +91,27 @@ interface SessionHeader {
## 格式拒绝:本构建无法可靠读取的日志
-后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于本格式版本的 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。
+后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时则要求一条完整注册的相邻版本迁移路径,否则会指出缺失步骤。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。
+
+## `SessionFormatMigration`:相邻静态格式升级
+
+每个迁移 class 声明一组相邻的 `from`/`to`,并为一次解码创建全新状态。decoder 会将每次 header 和事件输出快照为分离的无损 JSON,再交给下一项迁移,同时保留事件 seq;只有完整消费事件流后,才会调用可选的 EOF 验证。[包 README](../../packages/session/session-persistence/README.zh.md)负责说明注册与版本递增步骤。
+
+```ts type-equiv
+/** Static identity and constructor for one adjacent-version migration. */
+interface SessionFormatMigration {
+ /** Input Session format version. */
+ readonly from: number
+ /** Output Session format version; must equal `from + 1`. */
+ readonly to: number
+ /**
+ * Create fresh state for one header decode and its optional complete event
+ * stream. Instances are never shared across sessions or decode attempts.
+ * @returns a single-use migration instance.
+ */
+ new(): SessionFormatMigrationInstance
+}
+```
## `CreateSessionOptions`:seed 与元数据
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index 7771bc112d..b53bfdd9c7 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -24,7 +24,7 @@ import {
PersistenceCoordinator,
SessionPersistenceRevision,
type PersistenceBackend,
- type StoredPrefix,
+ type StoredSessionSource,
} from '@deepseek-ai/dsh-session-persistence'
import { ApiSessionList } from '../src/list.ts'
import {
@@ -359,19 +359,29 @@ describe('cold history recovery view', () => {
await ctx.plugin(SessionStore)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
- const stored: StoredPrefix = {
+ const revision = SessionPersistenceRevision('history-recovery-test:1')
+ const stored: StoredSessionSource = {
meta,
- events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
- revision: SessionPersistenceRevision('history-recovery-test:1'),
+ revision,
+ readEvents: ({ fromSeq = 0 } = {}) => ({
+ events: (async function* () {
+ const events: SessionEvent[] = [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ ]
+ for (const event of events.slice(fromSeq)) yield structuredClone(event)
+ })(),
+ completed: Promise.resolve({}),
+ }),
}
const backend: PersistenceBackend = {
name: 'history-recovery-test',
- loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
+ openStored: id => Promise.resolve(id === sessionId ? stored : undefined),
readStoredRevision: id => Promise.resolve(
- id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
+ id === sessionId ? revision : undefined,
),
appendBatch: () => Promise.resolve(),
commitRepair: () => Promise.resolve(),
+ replaceStored: () => Promise.resolve(),
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 720136b270..e800ed34d7 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: c1c40e39710d46fae240f0b3281c36d660855010
-README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36
+README.md: 3b7b37cf59aafc7292f75e9e4fb1513b93b32c1c
+README.zh.md: aee1a2ac842b34b284a0be5589b6dd801498e76b
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index c1c40e3971..3b7b37cf59 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Markdown rendering
-`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
+`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars); while a reply streams, a fence highlights incrementally as it grows — each chunk tokenizes newly completed text from the saved grammar state plus the still-growing last line, while completed lines keep their DOM — and the settled render swaps in shiki's HTML with an identical span tree ([decision record](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
## Terminal output
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 631936ad65..aee1a2ac84 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -14,7 +14,7 @@
## Markdown 渲染
-`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
+`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki);回复流式输出期间,围栏随内容增长而增量高亮——每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,已完成的行保持其 DOM 不变——定稿渲染换入的 shiki HTML 具有完全一致的 span 树([决策记录](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
## 终端输出
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index cd109874cb..ba9cd43392 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -1,7 +1,11 @@
-import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import type { ReactNode } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
-import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
+import {
+ StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
+} from './highlight.ts'
+import type { HighlightSpan } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -9,6 +13,14 @@ export interface CodeBlockProps {
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
+ /**
+ * The code is still growing (a streaming markdown fence): highlight through
+ * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
+ * appended text and keeps completed lines' elements (and DOM) untouched.
+ * The caller must keep the component instance stable across growth (a
+ * stream-stable React key); settled callers omit this and get shiki's HTML.
+ */
+ streaming?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
@@ -17,13 +29,61 @@ export interface CodeBlockProps {
copiedLabel: string
}
-export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) {
+/**
+ * The `pre` attributes shiki's HTML arm emits for the css-variables theme,
+ * mirrored so the streaming arm's tree is interchangeable with the settled
+ * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
+ * parity).
+ */
+const SHIKI_PRE_PROPS = {
+ className: 'shiki css-variables',
+ style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
+ tabIndex: 0,
+} as const
+
+export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
- const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
+ const html = useMemo(
+ () => (streaming === true ? undefined : highlightToHtml(trimmed, lang)),
+ [streaming, trimmed, lang, loaded],
+ )
+ // Streaming state lives in refs mutated inside the memo (the MarkdownText
+ // streaming-cache pattern): the session's caches carry across chunks only
+ // because the owner keys this instance stably while the fence grows.
+ const sessionRef = useRef(null)
+ const lineCacheRef = useRef<{ lines: readonly HighlightSpan[][]; elements: ReactNode[] } | null>(null)
+ const streamedBody = useMemo(() => {
+ if (streaming !== true) {
+ sessionRef.current = null
+ lineCacheRef.current = null
+ return undefined
+ }
+ sessionRef.current ??= new StreamingHighlightSession()
+ const lines = sessionRef.current.update(trimmed, lang)
+ if (lines === undefined) {
+ lineCacheRef.current = null
+ return undefined
+ }
+ // A retained line keeps its span-array identity across chunks, so its
+ // cached element is reused and React leaves that line's DOM untouched.
+ const previous = lineCacheRef.current
+ const elements = lines.map((line, index) => previous !== null && previous.lines[index] === line
+ ? previous.elements[index]
+ : (
+
+ {index > 0 && '\n'}
+
+ {line.map((span, spanIndex) => {span.text})}
+
+
+ ))
+ lineCacheRef.current = { lines, elements }
+ return
- )
- : (
- // shiki's output is a static span tree it generated from `code` (no user
- // HTML passes through), the sanctioned innerHTML consumption path per
+ // shiki's HTML output is a static span tree it generated from `code` (no
+ // user HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
-
- )
+ const body = streamedBody !== undefined
+ ? streamedBody
+ : html === undefined
+ ? (
+
{trimmed}
+ )
+ : (
+
+ )
return (
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index b4fc2d4678..3a26bac424 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -140,9 +140,10 @@ class StreamingRenderer {
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
- * `streaming` renders fences and TeX plain (highlighting and KaTeX land on
- * the finalize swap) and parses incrementally across chunks; `labels`
- * forwards localized fence and footnote chrome — pass a
+ * `streaming` parses incrementally across chunks and highlights fences as
+ * they grow (each fence re-tokenizes only appended text; TeX stays literal
+ * until the finalize swap so incomplete formulae never flash errors);
+ * `labels` forwards localized fence and footnote chrome — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message. `fileMentions`
* links inline-code tokens its resolver recognizes as real files; this is
diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts
index 16fb544dc0..047f9d1139 100644
--- a/packages/client/ui-primitives/src/markdown/highlight.ts
+++ b/packages/client/ui-primitives/src/markdown/highlight.ts
@@ -23,7 +23,7 @@ import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from '
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
-import type { HighlighterCore } from 'shiki/core'
+import type { GrammarState, HighlighterCore, ThemedToken } from 'shiki/core'
import type { CSSProperties } from 'react'
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
@@ -278,6 +278,146 @@ export interface HighlightSpan {
style: CSSProperties
}
+/** vscode-textmate FontStyle bits shiki folds into `text-decoration` values. */
+const DECORATION_BITS: readonly (readonly [number, string])[] = [[4, 'underline'], [8, 'line-through']]
+
+/**
+ * The inline style shiki's HTML arm assigns one token (`getTokenStyleObject`
+ * mirrored onto React style keys): the css-variables color plus the
+ * vscode-textmate font-style bits the theme lets through — italic (1), bold
+ * (2), and the {@link DECORATION_BITS} decorations (the theme injects bold,
+ * italic, and underline rules for markup scopes, so markdown fences carry
+ * them). The theme has no per-scope backgrounds, so `background-color` never
+ * occurs; the arm-parity tests fail loud if a shiki upgrade changes that.
+ */
+function spanStyle(token: ThemedToken): CSSProperties {
+ const style: CSSProperties = { color: token.color }
+ /* v8 ignore next -- fontStyle is optional in ThemedToken's type; tokenizeWithTheme always stamps it. */
+ const bits = token.fontStyle ?? 0
+ if ((bits & 1) !== 0) style.fontStyle = 'italic'
+ if ((bits & 2) !== 0) style.fontWeight = 'bold'
+ const decorations = DECORATION_BITS.filter(([bit]) => (bits & bit) !== 0)
+ if (decorations.length > 0) style.textDecoration = decorations.map(([, value]) => value).join(' ')
+ return style
+}
+
+/**
+ * Narrow one tokenized line to the runs a `` renders, folding a
+ * whitespace-only run into the token that follows it — shiki's default
+ * `mergeWhitespaces` HTML behavior — with each run styled through
+ * {@link spanStyle}, so the streaming spans and the settled `codeToHtml`
+ * swap render one identical span tree. shiki exempts underlined/struck
+ * whitespace from the fold; under the css-variables theme that case cannot
+ * occur — its only underline rule styles inline-link scopes, whose spaced
+ * text tokenizes as one run, and it injects no strikethrough rule — so the
+ * unconditional fold here stays equivalent (the markdown arm-parity test
+ * pins it). A line-trailing whitespace-only run has no follower and keeps
+ * its own span, as in shiki.
+ */
+function lineSpans(line: ThemedToken[]): HighlightSpan[] {
+ const spans: HighlightSpan[] = []
+ let pendingWhitespace = ''
+ for (const [index, token] of line.entries()) {
+ if (/^\s+$/.test(token.content) && index + 1 < line.length) {
+ pendingWhitespace += token.content
+ continue
+ }
+ spans.push({ text: pendingWhitespace + token.content, style: spanStyle(token) })
+ pendingWhitespace = ''
+ }
+ return spans
+}
+
+/**
+ * Incremental highlighter for one growing streaming fence. TextMate
+ * tokenization is line-based and forward-only — a line's tokens depend only on
+ * its own text and the grammar state entering it — so appended text never
+ * changes a completed line's tokens. The session caches the spans of every
+ * completed line together with the grammar state after them; each
+ * {@link update} tokenizes newly completed text from that state, plus the
+ * still-growing last line. Per-call cost therefore excludes the completed
+ * prefix, and the result equals a from-scratch tokenization of the same code.
+ * Non-append input and a change of resolved grammar reset the cache and
+ * re-tokenize fully, so any input stays correct.
+ */
+export class StreamingHighlightSession {
+ /** Grammar id the cache was built with; a different resolution resets it. */
+ private resolved: string | undefined
+ /** Newline-terminated source prefix covered by {@link spans}. */
+ private prefix = ''
+ /** Cached spans, one entry per completed line of {@link prefix}. */
+ private spans: HighlightSpan[][] = []
+ /** Grammar state after {@link prefix}; undefined = the grammar's initial state. */
+ private state: GrammarState | undefined
+ private lastCode: string | undefined
+ private lastLang: string | undefined
+ private lastResult: HighlightSpan[][] | undefined
+
+ private reset(resolved: string | undefined): void {
+ this.resolved = resolved
+ this.prefix = ''
+ this.spans = []
+ this.state = undefined
+ }
+
+ /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */
+ private tokenize(resolved: string, text: string): ThemedToken[][] {
+ return highlighter().codeToTokensBase(text, {
+ lang: resolved,
+ theme: 'css-variables',
+ ...(this.state === undefined ? {} : { grammarState: this.state }),
+ })
+ }
+
+ /**
+ * Tokenize the fence's current text into per-line highlighted runs;
+ * `undefined` means the caller renders its plain fallback. Idempotent per
+ * (`code`, `lang`) input — repeated calls return the identical result array —
+ * and a retained line keeps its span-array identity across growing calls, so
+ * a React caller can reuse cached line elements. A lazy grammar not yet
+ * loaded returns `undefined` and loads in the background exactly as
+ * {@link highlightToHtml} does; the next call after it registers highlights.
+ * @param code - the fence text accumulated so far (display-trimmed, no synthetic trailing newline).
+ * @param lang - the language hint (a markdown fence info string).
+ * @returns one entry per line of `code` (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
+ */
+ update(code: string, lang: string | undefined): readonly HighlightSpan[][] | undefined {
+ if (code === this.lastCode && lang === this.lastLang && this.lastResult !== undefined) {
+ return this.lastResult
+ }
+ this.lastCode = code
+ this.lastLang = lang
+ const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
+ if (resolved === undefined || !ensureGrammar(resolved)) {
+ this.reset(undefined)
+ this.lastResult = undefined
+ return undefined
+ }
+ if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved)
+ const rest = code.slice(this.prefix.length)
+ const lastNewline = rest.lastIndexOf('\n')
+ // Everything before the last newline is newly completed lines: tokenize
+ // them once from the cached state and retain their spans. What follows is
+ // the still-growing line, re-tokenized per call but never retained.
+ if (lastNewline >= 0) {
+ // Tokenize what shiki's own line splitting would see: splitLines strips
+ // the \r of a \r\n terminator (interior pairs are shiki's to split), so
+ // a CRLF cut must not leak its \r into the last completed line — a bash
+ // continuation's grammar state, for example, differs with it.
+ const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline
+ const tokens = this.tokenize(resolved, rest.slice(0, grownEnd))
+ // Per-line push, not one spread call: a reconnect can deliver the whole
+ // accumulated fence as one update, and spreading tens of thousands of
+ // lines into arguments can exceed the engine's argument limit.
+ for (const line of tokens) this.spans.push(lineSpans(line))
+ this.state = highlighter().getLastGrammarState(tokens)
+ this.prefix = code.slice(0, this.prefix.length + lastNewline + 1)
+ }
+ this.lastResult = [...this.spans, ...this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)]
+ return this.lastResult
+ }
+}
+
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
@@ -286,8 +426,9 @@ export interface HighlightSpan {
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
- * css-variables theme carries no font-style bits, matching that path's
- * color-only output. The trailing newline shiki appends as a final empty line
+ * markup font-style bits the theme lets through (bold/italic/underline in
+ * markdown scopes) are dropped — the line-numbered file view renders
+ * color-only runs. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index 55a9dadc42..950d85be52 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -126,7 +126,7 @@ export interface MarkdownFileMentions {
* numbering accumulated in document order while references render.
*/
export interface MarkdownRenderContext {
- /** Streaming arm: fences render plain and TeX stays literal. */
+ /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly labels: MarkdownLabels
@@ -334,7 +334,13 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
- lang={context.streaming ? undefined : lang}
+ lang={lang}
+ // Streaming keys are source offsets, stable while the fence grows, so
+ // the CodeBlock instance (and its incremental highlight session)
+ // survives every chunk. A fence whose info string is still mid-chunk
+ // has no content yet and took the empty-fence arm above, so `lang`
+ // here is final: it can never re-resolve to a different grammar.
+ streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
/>
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
index 2047d32215..078279374b 100644
--- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
@@ -3,12 +3,25 @@