From cc9ab200c77ba2b4077c6b514a9f019adb358c7e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 17:40:26 +0800 Subject: [PATCH 01/37] feat(session): add format migration decoder pipeline --- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.md | 2 +- .../2026-06-14-session-persistence.zh.md | 2 +- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 15 +- ...-08-10-session-log-version-mechanism.zh.md | 15 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 +- .../session-persistence-jsonl/src/format.ts | 71 +- .../session-persistence-jsonl/src/index.ts | 318 ++++++- .../session-persistence-jsonl/src/win32.ts | 14 + .../tests/jsonl.spec.ts | 352 ++++++- .../tests/win32.spec.ts | 36 + .../tests/zstd.spec.ts | 52 +- .../session-persistence-sqlite/src/index.ts | 208 +++- .../session-persistence-sqlite/src/schema.ts | 22 +- .../tests/sqlite.spec.ts | 215 ++++- .../session-persistence/src/coordinator.ts | 674 +++---------- .../session-persistence/src/format-decoder.ts | 480 ++++++++++ .../src/format-migrations/index.ts | 6 + .../src/format-v0-compat.ts | 293 ++++++ .../session/session-persistence/src/index.ts | 21 +- .../session-persistence/src/revision.ts | 9 + .../tests/format-decoder.spec.ts | 889 ++++++++++++++++++ .../tests/persistence.spec.ts | 367 +++++++- 27 files changed, 3406 insertions(+), 697 deletions(-) create mode 100644 packages/session/session-persistence/src/format-decoder.ts create mode 100644 packages/session/session-persistence/src/format-migrations/index.ts create mode 100644 packages/session/session-persistence/src/format-v0-compat.ts create mode 100644 packages/session/session-persistence/tests/format-decoder.spec.ts 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 61f9754431..014ea6dd12 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: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee +2026-06-14-session-persistence.md: cef11271f26c304ad484d7851801bc69d0c1dfda +2026-06-14-session-persistence.zh.md: ecc85439bb47d6c1fb3280f9c9716d29f3684aea 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 b10ceebd95..ecc85439bb 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.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取接受当前版本或完整的静态相邻版本 decoder 路径,并拒绝未来版本或缺失步骤。Format decoder 负责历史 header 和 event 转换,Coordinator 只在解码后负责各操作自己的 recovery([Session log 版本机制](2026-08-10-session-log-version-mechanism.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 fa18284fad..2d9bfcd187 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: 25eb1230a254219c827b1d2750dba367b113f9f7 -2026-08-10-session-log-version-mechanism.zh.md: ac1088527ba14a8018c5a7fb3c77c9232bd3b3a9 +2026-08-10-session-log-version-mechanism.md: eb9fcef3fa677ee3912c2cbd611601b6b6f83db1 +2026-08-10-session-log-version-mechanism.zh.md: aa257482869a2b18bd0d3fcb2bc883a8cf506dcf 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 25eb1230a2..eb9fcef3fa 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 `SessionFormatStep`s; a missing step 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 step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. + +**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. 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 steps. + +**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 inside the commit exclusion. JSONL writes and fsyncs a sibling temporary artifact, rechecks the revision while holding its cross-process lock, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX. 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 today'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 iterator 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 ac1088527b..aa25748286 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 `SessionFormatStep` 组成完整链路,缺失任何一步都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 + +**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 + +**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。 + +**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,在持有跨进程锁时复核 revision,原子替换路径(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录。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 加 iterator 转换保留重试语义,又不强制这笔分配。 +- **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。 +- **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 01ab6a6605..4fb0df3fdc 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: 3a471ea06e911d3d29ebef4ce80353b15a21cb43 -config-catalog.zh.md: f1b53d4a2b3c15abb3176c7af5de9c577f74825f +config-catalog.md: 8f3ed736b565b2251bc8418bd940f222f323f0b6 +config-catalog.zh.md: f494277de8f820abe318a260dfbf41dc54e0c947 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3a471ea06e..8f3ed736b5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1590,7 +1590,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1635,7 +1635,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f1b53d4a2b..f494277de8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1592,7 +1592,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -来源:[`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) +来源:[`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -1637,7 +1637,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index ef642082ac..4f96170355 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -21,7 +21,7 @@ import { PersistenceCoordinator, SessionPersistenceRevision, type PersistenceBackend, - type StoredPrefix, + type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -271,19 +271,29 @@ describe('cold history recovery view', () => { await ctx.plugin(UserQuestionService) 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/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 2923b9e09b..bf6d1fde2d 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -11,7 +11,6 @@ import { join } from 'node:path' import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' -import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -224,29 +223,26 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean) } interface SessionLogScan { - meta: SessionHeader - events: SessionEvent[] + meta: unknown + events: unknown[] committedBytes: number } -/** Parse one complete header record supplied independently from event rows. */ -/** - * Refuse a header carrying a format version this build does not read BEFORE - * validating the current header shape or decoding any event row: a future - * format need not satisfy today's structural checks at all, and its user must - * see "upgrade the harness", never "corrupt session log". - * @param parsed - the JSON-parsed first line of a session artifact. - */ -function refuseForeignFormatVersion(parsed: unknown): void { - if (typeof parsed !== 'object' || parsed === null) return - const { version, id } = parsed as { version?: unknown; id?: unknown } - if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return - throw new SessionFormatUnsupportedError( - sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version), - ) +/** Parse the version-independent identity fields from one physical header row. */ +function parseStoredHeader(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined + const record = value as Record + if (record['type'] !== 'session' + || !Number.isSafeInteger(record['version'])) return undefined + if (record['version'] === SESSION_FORMAT_VERSION) { + return isHeaderLine(record) ? fromHeaderLine(record) as unknown as Record : undefined + } + const { type: _type, ...meta } = record + return meta } -function parseHeaderRecord(record: Buffer): SessionHeader { +/** Parse one complete header record supplied independently from event rows. */ +function parseHeaderRecord(record: Buffer): unknown { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') } @@ -256,11 +252,11 @@ function parseHeaderRecord(record: Buffer): SessionHeader { } catch { throw new Error('corrupt session log: header line is not valid JSON') } - refuseForeignFormatVersion(parsed) - if (!isHeaderLine(parsed)) { + const meta = parseStoredHeader(parsed) + if (meta === undefined) { throw new Error('corrupt session log: first line is not a session header') } - return fromHeaderLine(parsed) + return meta } /** @@ -270,8 +266,8 @@ function parseHeaderRecord(record: Buffer): SessionHeader { * copied because a decoder may reuse its output buffer after `write()` returns. */ export class SessionLogScanner { - private readonly meta: SessionHeader - private readonly events: SessionEvent[] = [] + private readonly meta: unknown + private readonly events: unknown[] = [] private fragments: Buffer[] = [] private fragmentBytes = 0 private inputBytes: number @@ -346,7 +342,7 @@ export class SessionLogScanner { /** Decode one complete event row and update the contiguous prefix. */ private consumeEventLine(line: Buffer, endByte: number): void { this.eventLine += 1 - let decoded: SessionEvent[] + let decoded: unknown[] try { decoded = decodeStorageRecord(JSON.parse(line.toString('utf8'))) } catch { @@ -355,20 +351,21 @@ export class SessionLogScanner { } if (this.issue !== undefined) { - if (decoded.some(event => event.type === 'turn/end')) throw this.issue + if (decoded.some(event => (event as { type?: unknown }).type === 'turn/end')) throw this.issue return } const rowStart = this.events.length for (const event of decoded) { - if (event.seq !== this.events.length) { + const seq = (event as { seq?: unknown }).seq + if (seq !== this.events.length) { const expected = this.events.length this.events.length = rowStart this.issue = new Error( `corrupt session log: seq gap in committed region at line ${this.eventLine} ` - + `(expected ${expected}, got ${event.seq})`, + + `(expected ${expected}, got ${String(seq)})`, ) - if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue + if (decoded.some(candidate => (candidate as { type?: unknown }).type === 'turn/end')) throw this.issue return } this.events.push(event) @@ -394,20 +391,18 @@ export function scanLog(buffer: Buffer): SessionLogScan { } /** - * Parse just the header line of a log into a {@link SessionHeader}, or - * `undefined` if it is missing/not a header. Used by `list()` to read session - * metadata WITHOUT parsing the whole log: a session picker scales with the - * number of sessions, not the total size of every conversation. - * @param firstLine - the first line of a log file (without its trailing newline). - * @returns the parsed header, or `undefined` when the line is not a well-formed session header. + * Parse only the version-independent identity envelope from one physical + * header line. Format migration and current validation run in the persistence + * decoder. + * @param firstLine - first JSONL record without its newline. + * @returns normalized logical header JSON, or `undefined` for invalid framing. */ -export function parseHeaderMeta(firstLine: string): SessionHeader | undefined { +export function parseStoredHeaderMeta(firstLine: string): unknown { let parsed: unknown try { parsed = JSON.parse(firstLine) } catch { return undefined } - if (!isHeaderLine(parsed)) return undefined - return fromHeaderLine(parsed) + return parseStoredHeader(parsed) } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 5113746fec..e16f343c8f 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -9,28 +9,30 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, + decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, - type StoredPrefix, + type StoredEventRead, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, + encodeSegment, eventLines, logPath, logSuffix, parseStoredHeaderMeta, projectDir, scanLog, sessionDir, SessionLogScanner, toHeaderLine, type JsonlCompression, } from './format.ts' import { compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames, } from './zstd.ts' -import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' +import { ensureDurableDirectoryWin32, publishNewFileWin32, replaceFileWin32 } from './win32.ts' export type { JsonlCompression } from './format.ts' @@ -42,6 +44,10 @@ const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' * remains an indivisible synchronous decode. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 +const LOG_LOCK_RETRY_INITIAL_MS = 20 +const LOG_LOCK_RETRY_MAX_MS = 200 +const LOG_LOCK_TIMEOUT_MS = 2_000 +const REPLACEMENT_BATCH_SIZE = 128 /** Assert that the independently decodable first frame contains only the header record. */ function assertZstdHeaderFrame(plaintext: Buffer): void { @@ -88,6 +94,32 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface JsonlStoredPrefix { + readonly meta: unknown + readonly events: unknown[] + readonly revision: PersistenceRevision + readonly tornMarker?: JsonlTornMarker +} + +interface JsonlStoredHeader { + readonly meta: unknown + readonly revision: PersistenceRevision +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + interface FileRevisionIdentity { readonly dev: bigint readonly ino: bigint @@ -112,6 +144,10 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +function isEEXIST(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' +} + /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence` and (via the coordinator) installs the write-path @@ -193,8 +229,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return this.coordinator.inspect(id, signal) } - // JSONL is sequential media: no loadStoredFrom hook, so the coordinator - // parses the stored prefix (both encodings) and skips forward to fromSeq. + // JSONL is sequential media: its source reader parses the stored prefix and + // filters only after physical framing and sequence checks. readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return this.coordinator.readFrom(id, fromSeq, signal) } @@ -205,14 +241,43 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /* jscpd:ignore-end */ // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - /** Read a stored prefix by id across all project directories when cwd is unknown. */ - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + /** Open repeatable reads over one revision resolved across project directories. */ + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { signal?.throwIfAborted() await this.ensureRootEncoding() signal?.throwIfAborted() const path = await this.findLog(id, signal) if (path === undefined) return undefined - return this.readPrefix(path, id, signal) + const { meta, revision } = await this.readStoredHeader(path, id, signal) + return { + meta, + revision, + location: { kind: 'jsonl', path }, + readEvents: (options = {}): StoredEventRead => { + const completed = deferred<{ tornMarker?: JsonlTornMarker }>() + const events = (async function* (backend: JsonlSessionPersistence): AsyncIterable { + try { + const prefix = await backend.readPrefix(path, id, signal) + if (prefix.revision !== revision) { + throw new SessionPersistenceRevisionConflictError( + `session "${id}" changed while reading revision ${revision}`, + ) + } + const fromSeq = options.fromSeq ?? 0 + for (const event of prefix.events) { + signal?.throwIfAborted() + const seq = (event as { seq?: unknown }).seq + if (typeof seq !== 'number' || seq >= fromSeq) yield event + } + completed.resolve(prefix.tornMarker === undefined ? {} : { tornMarker: prefix.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })(this) + return { events, completed: completed.promise } + }, + } } /** @@ -272,10 +337,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } else { content = buffer.toString('utf8') } - const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) - if (meta === undefined || meta.id !== id) { + const rawMeta = parseStoredHeaderMeta(content.split('\n', 1)[0] as string) + if (rawMeta === undefined) { throw new Error(`corrupt session log: invalid header line in "${path}"`) } + const meta = decodeStoredSessionHeader(rawMeta, id, { kind: 'jsonl', path }) // The logical artifact name is `session.jsonl` regardless of the physical // encoding suffix (`.jsonl.zstd` marks compression only). return { meta, filename: 'session.jsonl', content } @@ -303,6 +369,31 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } + /** Read one version-independent header at a stable file revision. */ + private async readStoredHeader( + path: string, + _expectedId?: SessionId, + signal?: AbortSignal, + ): Promise { + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + const firstLine = this.compression === 'zstd' + ? await this.readFirstZstdLine(path, signal) + : await this.readFirstLine(path, signal) + const after = fileRevision(await stat(path, { bigint: true })) + if (before !== after) continue + if (firstLine === undefined) { + throw new Error(this.compression === 'zstd' + ? `empty or header-less Zstandard session log at "${path}"` + : `empty or header-less session log at "${path}"`) + } + const meta = parseStoredHeaderMeta(firstLine) + if (meta === undefined) throw new Error(`corrupt session log: first line is not a session header in "${path}"`) + return { meta, revision: after } + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -311,32 +402,22 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi path: string, expectedId?: SessionId, signal?: AbortSignal, - ): Promise> { + ): Promise { const { buffer, revision } = await this.readStableFile(path, signal) - let prefix: Omit, 'revision'> - try { - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, - } + let prefix: Omit + if (this.compression === 'zstd') { + prefix = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + prefix = { + meta, + events, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, } - } catch (error: unknown) { - // A parse-time format refusal predates any SessionHeader, so the - // coordinator's locate-based enrichment cannot run; attach the artifact - // this read actually refused. - if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { - throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) - } - throw error } signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) @@ -348,7 +429,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi private async readZstdPrefix( buffer: Buffer, signal?: AbortSignal, - ): Promise, 'revision'>> { + ): Promise> { signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) signal?.throwIfAborted() @@ -406,7 +487,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi events: recoveredPrefix.events, tornMarker: { truncateTo: tornStart, - recoveredEvents: recoveredPrefix.events.slice(complete.eventCount), + recoveredEvents: recoveredPrefix.events.slice(complete.eventCount) as SessionEvent[], }, } } catch (error) { @@ -422,7 +503,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ensureRootEncoding() if (isMaterialized) { - await this.appendLines(meta, events) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) + await this.withLogLock(path, () => this.appendLines(meta, events)) } else { await this.materialize(meta, events) } @@ -438,9 +520,69 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi tornMarker: JsonlTornMarker | undefined, closers: readonly SessionEvent[], ): Promise { - if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) - const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] - if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + const path = logPath(this.root, meta.cwd, meta.id, this.compression) + await this.withLogLock(path, async () => { + if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) + const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] + if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) + }) + } + + /** Replace one exact source revision through a synced sibling and atomic namespace update. */ + async replaceStored( + expectedRevision: PersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + await this.ensureRootEncoding() + const path = await this.findLog(meta.id) + if (path === undefined) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) + } + await this.withLogLock(path, async () => { + let current: JsonlStoredHeader + try { + current = await this.readStoredHeader(path, meta.id) + } catch (error: unknown) { + if (isENOENT(error)) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) + } + throw error + } + if (current.revision !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + const currentIdentity = this.storedIdentity(current.meta, path) + if (meta.cwd !== currentIdentity.cwd) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` + try { + await this.writeReplacement(tmp, meta, events) + const beforeCommit = fileRevision(await stat(path, { bigint: true })) + if (beforeCommit !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + /* v8 ignore next -- Windows uses its write-through replacement primitive. */ + if (process.platform === 'win32') { + await replaceFileWin32(tmp, path) + } else { + await rename(tmp, path) + await this.syncDirPosix(dirname(path)) + } + } finally { + await rm(tmp, { force: true }) + } + }) } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ @@ -493,9 +635,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi : await this.readFirstLine(path, signal) signal?.throwIfAborted() if (first === undefined) continue // empty/half-written file - const meta = parseHeaderMeta(first) - if (meta === undefined) continue // not a session header - await this.assertStoredIdentity(path, meta, undefined, signal) + const rawMeta = parseStoredHeaderMeta(first) + if (rawMeta === undefined) continue // not a session header + const identity = this.storedIdentity(rawMeta, path) + const meta = decodeStoredSessionHeader(rawMeta, identity.id, { kind: 'jsonl', path }) + await this.assertStoredIdentity(path, rawMeta, undefined, signal) signal?.throwIfAborted() if (ids.has(meta.id)) { throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`) @@ -615,6 +759,59 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return tmp } + /** Stream one complete current-format replacement into a synced temp file. */ + private async writeReplacement( + path: string, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + const handle = await open(path, 'wx', 0o600) + try { + const header = JSON.stringify(toHeaderLine(meta)) + '\n' + await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(header) : header) + let batch: SessionEvent[] = [] + const writeBatch = async (): Promise => { + if (batch.length === 0) return + const body = eventLines(batch, this.packChunks) + '\n' + await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(body) : body) + batch = [] + } + for await (const event of events) { + batch.push(event) + if (batch.length === REPLACEMENT_BATCH_SIZE) await writeBatch() + } + await writeBatch() + await handle.sync() + } finally { + await handle.close() + } + } + + /** Serialize cooperating cross-process mutations of one materialized log. */ + private async withLogLock(path: string, operation: () => Promise): Promise { + const lockPath = `${path}.lock` + const deadline = Date.now() + LOG_LOCK_TIMEOUT_MS + let delay = LOG_LOCK_RETRY_INITIAL_MS + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) + break + } catch (error: unknown) { + if (!isEEXIST(error)) throw error + } + if (Date.now() >= deadline) { + throw new Error(`session log writer lock timed out at "${lockPath}"`) + } + await new Promise(resolve => setTimeout(resolve, delay)) + delay = Math.min(delay * 2, LOG_LOCK_RETRY_MAX_MS) + } + try { + return await operation() + } finally { + await rm(lockPath, { force: true }) + } + } + /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const header = JSON.stringify(toHeaderLine(meta)) + '\n' @@ -807,26 +1004,45 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /** Reject metadata that does not identify the selected physical log. */ private async assertStoredIdentity( path: string, - meta: SessionHeader, + meta: unknown, expectedId?: SessionId, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() - if (expectedId !== undefined && meta.id !== expectedId) { - throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`) + const identity = this.storedIdentity(meta, path) + if (expectedId !== undefined && identity.id !== expectedId) { + throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${identity.id}"`) } let expectedPath: string try { - expectedPath = logPath(this.root, meta.cwd, meta.id, this.compression) + expectedPath = logPath(this.root, identity.cwd, identity.id, this.compression) } catch (error) { throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error }) } if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) { - throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`) + throw new Error(`corrupt session log "${path}": header id "${identity.id}" and cwd identify "${expectedPath}"`) } signal?.throwIfAborted() } + /** Read storage identity fields shared by every Session format version. */ + private storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string } { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + throw new Error(`corrupt session log "${path}": header is not a record`) + } + const record = meta as Record + if (typeof record['id'] !== 'string') { + throw new Error(`corrupt session log "${path}": header id is not a string`) + } + if (record['cwd'] !== undefined && typeof record['cwd'] !== 'string') { + throw new Error(`corrupt session log "${path}": header cwd is not a string`) + } + return { + id: SessionId(record['id']), + ...typeof record['cwd'] === 'string' ? { cwd: record['cwd'] } : {}, + } + } + /** * Whether two path spellings resolve to the same physical file. This admits * case aliases on case-insensitive filesystems without weakening identity diff --git a/packages/session/session-persistence-jsonl/src/win32.ts b/packages/session/session-persistence-jsonl/src/win32.ts index c3fa852b08..51456bd740 100644 --- a/packages/session/session-persistence-jsonl/src/win32.ts +++ b/packages/session/session-persistence-jsonl/src/win32.ts @@ -28,6 +28,7 @@ interface Win32ErrnoException extends NodeJS.ErrnoException { } const MOVEFILE_WRITE_THROUGH = 0x00000008 +const MOVEFILE_REPLACE_EXISTING = 0x00000001 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 @@ -119,6 +120,19 @@ export async function publishNewFileWin32(existing: string, replacement: string) if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) } +/** + * Atomically replace an existing file with a synced staging file and request + * write-through namespace durability. The move stays within one volume. + * @param existing - synced staging path to move. + * @param replacement - existing final path to replace. + */ +export async function replaceFileWin32(existing: string, replacement: string): Promise { + const api = await win32() + const flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH + const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), flags) + if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) +} + /** * Create `target` and its missing ancestors with durable Windows namespace * publication. Each missing directory is first created as a random staging diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 70ae6d1ac5..ce6e99f30c 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -8,7 +8,12 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { - encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine, + SessionPersistenceRevisionConflictError, + type StoredEventRead, +} from '@deepseek-ai/dsh-session-persistence' +import { + encodeSegment, eventLines, fromHeaderLine, logPath, parseStoredHeaderMeta, projectDir, projectKey, scanLog, sessionDir, + SessionLogScanner, toHeaderLine, } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -16,6 +21,8 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p const statRace = vi.hoisted(() => ({ path: undefined as string | undefined, reads: 0, + renamePath: undefined as string | undefined, + renameError: undefined as Error | undefined, })) vi.mock('node:fs/promises', async (importOriginal) => { @@ -29,6 +36,25 @@ vi.mock('node:fs/promises', async (importOriginal) => { if (statRace.reads !== 2) return identity return { ...identity, mtimeNs: identity.mtimeNs + 1n } }) as typeof actual.stat, + rename: async (...args: Parameters) => { + if (String(args[1]) === statRace.renamePath && statRace.renameError !== undefined) { + throw statRace.renameError + } + return actual.rename(...args) + }, + } +}) + +vi.mock('../src/win32.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + replaceFileWin32: async (existing: string, replacement: string) => { + if (replacement === statRace.renamePath && statRace.renameError !== undefined) { + throw statRace.renameError + } + return actual.replaceFileWin32(existing, replacement) + }, } }) @@ -42,6 +68,17 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader { return header } +async function collectStoredRead(read: StoredEventRead): Promise { + const events: unknown[] = [] + for await (const event of read.events) events.push(event) + await read.completed + return events +} + +async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable { + for (const event of events) yield structuredClone(event) +} + /** Rewrite only a stored header while preserving every event byte below it. */ async function rewriteHeader(path: string, update: (header: Record) => void): Promise { const lines = (await readFile(path, 'utf8')).split('\n') @@ -86,6 +123,8 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin afterEach(async () => { statRace.path = undefined statRace.reads = 0 + statRace.renamePath = undefined + statRace.renameError = undefined vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) @@ -134,6 +173,18 @@ runCoordinatorContract('jsonl-none', async (): Promise => { }) describe('JsonlSessionPersistence: format helpers', () => { + it('parses only the version-independent stored header envelope', () => { + expect(parseStoredHeaderMeta('{')).toBeUndefined() + expect(parseStoredHeaderMeta('42')).toBeUndefined() + expect(parseStoredHeaderMeta(JSON.stringify({ type: 'event', version: 9, id: 'wrong-type' }))) + .toBeUndefined() + expect(parseStoredHeaderMeta(JSON.stringify({ type: 'session', version: 9, id: 'future', futureOnly: true }))) + .toEqual({ version: 9, id: 'future', futureOnly: true }) + expect(parseStoredHeaderMeta(JSON.stringify({ + type: 'session', version: 0, id: 'current', createdAt: 1, delegationDepth: 0, + }))).toEqual({ version: 0, id: 'current', createdAt: 1, delegationDepth: 0 }) + }) + it('encodeSegment neutralizes traversal, separators, and absolute paths', () => { expect(encodeSegment('..')).toBe('~002E~002E') expect(encodeSegment('.')).toBe('~002E') @@ -299,7 +350,8 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + expect(scanned.events.map(event => (event as SessionEvent).type)) + .toEqual(oneTurnLog().map(event => event.type)) }) it('readRaw is undefined for an absent session', async () => { @@ -397,28 +449,287 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { + it('binds a stored source to the same revision as a lightweight read', async () => { const m = meta('stored-prefix-revision') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = ctx.sessionPersistence as JsonlSessionPersistence - const stored = await persistence.loadStored(m.id) + const stored = await persistence.openStored(m.id) expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() }) - it('retries a full-prefix read when the file revision changes during the read', async () => { + it('retries a revision-bound source read when the file changes during the read', async () => { const m = meta('stored-prefix-revision-race') await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const stored = await persistence.openStored(m.id) + if (stored === undefined) throw new Error('test session must be materialized') statRace.path = rawLogPath(root, m.cwd, m.id) - await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() }) + await expect(collectStoredRead(stored.readEvents())).resolves.toEqual(oneTurnLog()) expect(statRace.reads).toBe(4) }) + it('rejects a revision-bound source after a complete append changes its revision', async () => { + const m = meta('stored-source-stale') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const stored = await persistence.openStored(m.id) + if (stored === undefined) throw new Error('test session must be materialized') + + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } }, + ]) + + const read = stored.readEvents() + const completion = read.completed.catch((error: unknown) => error) + await expect(collectStoredRead(read)).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(completion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + }) + + it('retries a header read whose revision changes around the first-line read', async () => { + const m = meta('stored-header-revision-race') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const path = rawLogPath(root, m.cwd, m.id) + const internals = persistence as unknown as { + findLog(id: SessionId): Promise + } + vi.spyOn(internals, 'findLog').mockResolvedValue(path) + statRace.path = path + + await expect(persistence.openStored(m.id)).resolves.toMatchObject({ meta: { id: m.id } }) + expect(statRace.reads).toBe(4) + }) + + it('reports a present empty plaintext artifact as header-less', async () => { + const m = meta('empty-plaintext-log') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, m.cwd, m.id), '') + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + + await expect(persistence.openStored(m.id)) + .rejects.toThrow('empty or header-less session log') + }) + + it('forwards prepare through the concrete backend API', async () => { + const m = meta('jsonl-prepare-forward') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + + const preparation = await persistence.prepare(m.id) + expect(preparation.session.id).toBe(m.id) + preparation[Symbol.dispose]() + }) + + it('atomically replaces one exact revision and rejects a stale replacement', async () => { + const m = meta('format-replace', '/work') + const original = [ + ...oneTurnLog(), + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, original) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog())) + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(replaced.revision).not.toBe(source.revision) + expect(await collectStoredRead(replaced.readEvents())).toEqual(oneTurnLog()) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents(original)), + ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + const afterConflict = await persistence.openStored(m.id) + if (afterConflict === undefined) throw new Error('conflict must preserve the session') + expect(await collectStoredRead(afterConflict.readEvents())).toEqual(oneTurnLog()) + }) + + it('preserves the old complete log when atomic replacement rename fails', async () => { + const m = meta('format-replace-rename-failure', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + const failure = new Error('simulated format replacement rename failure') + statRace.renamePath = path + statRace.renameError = failure + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents([])), + ).rejects.toBe(failure) + + statRace.renameError = undefined + const preserved = await persistence.openStored(m.id) + if (preserved === undefined) throw new Error('failed replacement must preserve the session') + expect(await collectStoredRead(preserved.readEvents())).toEqual(oneTurnLog()) + expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false) + }) + + it('rejects replacement when the artifact disappears before or after discovery', async () => { + const m = meta('format-replace-disappeared', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + + await rm(path) + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + + const internals = persistence as unknown as { + findLog(id: SessionId): Promise + } + vi.spyOn(internals, 'findLog').mockResolvedValue(path) + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + }) + + it('propagates a non-absence error while rechecking a replacement source', async () => { + const m = meta('format-replace-header-error', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const failure = new Error('simulated header read failure') + const internals = persistence as unknown as { + readStoredHeader(path: string, id: SessionId): Promise + } + vi.spyOn(internals, 'readStoredHeader').mockRejectedValue(failure) + + await expect(persistence.replaceStored(source.revision, m, replacementEvents([]))) + .rejects.toBe(failure) + }) + + it('rejects a replacement that changes cwd storage identity', async () => { + const m = meta('format-replace-identity', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await expect(persistence.replaceStored( + source.revision, + { ...m, cwd: '/other' }, + replacementEvents(oneTurnLog()), + )).rejects.toThrow(/changes its stored identity/) + }) + + it('rejects a replacement when the source changes after the temp file is synced', async () => { + const m = meta('format-replace-final-cas', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const path = rawLogPath(root, m.cwd, m.id) + const internals = persistence as unknown as { + writeReplacement(path: string, meta: SessionHeader, events: AsyncIterable): Promise + } + const writeReplacement = internals.writeReplacement.bind(internals) + vi.spyOn(internals, 'writeReplacement').mockImplementation(async (...args) => { + await writeReplacement(...args) + await appendFile(path, '\n') + }) + + await expect(persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false) + }) + + it('streams replacement events in bounded batches', async () => { + const m = meta('format-replace-batches', '/work') + const events = Array.from({ length: 128 }, (_, seq): SessionEvent => ({ + type: 'turn/start', seq, time: seq + 1, data: { turn: seq + 1 }, + })) + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(events)) + + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(await collectStoredRead(replaced.readEvents())).toHaveLength(128) + }) + + it('retries a held writer lock and rejects lock acquisition failures and timeout', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const internals = persistence as unknown as { + withLogLock(path: string, operation: () => Promise): Promise + } + const path = join(root, 'lock-target') + const lockPath = `${path}.lock` + await writeFile(lockPath, 'held\n') + const released = new Promise((resolveRelease) => { + setTimeout(() => { void rm(lockPath).then(() => { resolveRelease() }) }, 5) + }) + await expect(internals.withLogLock(path, async () => 'committed')).resolves.toBe('committed') + await released + + await expect(internals.withLogLock(`${root}\0invalid`, async () => undefined)) + .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + + await writeFile(lockPath, 'held\n') + const now = vi.spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(3_001) + await expect(internals.withLogLock(path, async () => undefined)) + .rejects.toThrow(/writer lock timed out/) + now.mockRestore() + }) + + it('rejects malformed version-independent storage identity fields', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const path = rawLogPath(root, '/work', SessionId('identity-fields')) + const internals = persistence as unknown as { + storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string } + } + + expect(() => internals.storedIdentity(null, path)).toThrow(/header is not a record/) + expect(() => internals.storedIdentity({ id: 1 }, path)).toThrow(/header id is not a string/) + expect(() => internals.storedIdentity({ id: 'identity-fields', cwd: 1 }, path)) + .toThrow(/header cwd is not a string/) + }) + + it('rejects a physical log whose requested id differs from its header id', async () => { + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const requested = SessionId('requested-identity') + const path = rawLogPath(root, '/work', requested) + const internals = persistence as unknown as { + assertStoredIdentity( + path: string, + meta: unknown, + expectedId?: SessionId, + ): Promise + } + + await expect(internals.assertStoredIdentity( + path, + { id: 'different-identity', cwd: '/work' }, + requested, + )).rejects.toThrow(/requested id .* does not match header id/) + }) + it('handles revision-stat races and errors after log discovery', async () => { const m = meta('stored-revision-race') await ctx.sessionPersistence.create(m) @@ -748,7 +1059,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const beforeB = await readFile(bPath) await expect(ctx.sessionPersistence.load(a.id)) - .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/) + .rejects.toThrow(/identity mismatch: requested "identity-a", header contains "identity-b"/) expect(await readFile(aPath)).toEqual(beforeA) expect(await readFile(bPath)).toEqual(beforeB) }) @@ -939,7 +1250,20 @@ describe('JsonlSessionPersistence: scanLog unit', () => { // The preset decides the resumed session's tools and prompt; dropping it // on disk would restore a composition the logged history contradicts. - expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal') + expect((scanLog(Buffer.from(log)).meta as SessionHeader).agentPreset).toBe('minimal') + }) + + it('round-trips and validates a subagent origin', () => { + const header: SessionHeader = { + ...meta('subagent-origin'), + delegationDepth: 1, + origin: 'subagent', + } + const line = toHeaderLine(header) + + expect(fromHeaderLine(line)).toEqual(header) + expect(() => scanLog(Buffer.from(`${JSON.stringify({ ...line, origin: 'parent' })}\n`))) + .toThrow(/session header/) }) it('rejects a session header whose agentPreset is not a string', () => { @@ -957,7 +1281,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn. - expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) + expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0]) }) it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => { @@ -997,7 +1321,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { ].join('\n') + '\n' // The contiguous prefix (turn/start seq 0) is preserved; the corrupt // fragment after it is the tolerated crash boundary. - expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) + expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0]) }) it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => { @@ -1008,7 +1332,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => { JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail ].join('\n') + '\n' const { events } = scanLog(Buffer.from(log)) - expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped + expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1]) // tail dropped }) }) @@ -1129,7 +1453,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }), ].join('\n') + '\n' const { events } = scanLog(Buffer.from(logText)) - expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4]) + expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1, 2, 3, 4]) expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } }) }) @@ -1151,7 +1475,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => { JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }), ].join('\n') + '\n' const scanned = scanLog(Buffer.from(logText)) - expect(scanned.events.map(e => e.seq)).toEqual([0]) + expect(scanned.events.map(e => (e as SessionEvent).seq)).toEqual([0]) // committedBytes stays on the line boundary BEFORE the dropped row. const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n' expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8')) @@ -1393,7 +1717,7 @@ describe('JsonlSessionPersistence: edge cases', () => { // The "/w" log is untouched — no no-cwd events were grafted onto it, and no // `_no-cwd` log for "x" was created. const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x')))) - expect(inW.meta.cwd).toBe('/w') + expect((inW.meta as SessionHeader).cwd).toBe('/w') expect(inW.events).toHaveLength(6) await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow() await ctx2.fiber.dispose() diff --git a/packages/session/session-persistence-jsonl/tests/win32.spec.ts b/packages/session/session-persistence-jsonl/tests/win32.spec.ts index 3b6cfc4f78..33b8d2328f 100644 --- a/packages/session/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/win32.spec.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' const MOVEFILE_WRITE_THROUGH = 0x00000008 +const MOVEFILE_REPLACE_EXISTING = 0x00000001 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 @@ -90,6 +91,17 @@ async function importWithFilesystemMove(): Promise { + return importWithMove((existing, replacement, flags, setLastError) => { + expect(flags).toBe(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + const from = stripNamespace(existing) + const to = stripNamespace(replacement) + if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 } + renameSync(from, to) + return 1 + }) +} + afterEach(async () => { vi.doUnmock('koffi') vi.doUnmock('node:fs/promises') @@ -141,6 +153,30 @@ describe('Windows durable namespace helpers', () => { expect(readFileSync(final, 'utf8')).toBe('content') }) + it('replaces an existing file with write-through MoveFileExW semantics', async () => { + const { replaceFileWin32 } = await importWithFilesystemReplace() + const root = await tempRoot() + const tmp = join(root, 'log.tmp') + const final = join(root, 'log.jsonl') + await writeFile(tmp, 'replacement') + await writeFile(final, 'original') + + await replaceFileWin32(tmp, final) + expect(existsSync(tmp)).toBe(false) + expect(readFileSync(final, 'utf8')).toBe('replacement') + }) + + it('maps a Win32 replacement failure to a Node-style error', async () => { + const { replaceFileWin32 } = await importWithError(ERROR_ACCESS_DENIED) + + await expect(replaceFileWin32('from', 'to')).rejects.toMatchObject({ + code: 'EACCES', + win32Code: ERROR_ACCESS_DENIED, + path: 'from', + dest: 'to', + }) + }) + it('maps Win32 publish failures to Node-style errno codes', async () => { const cases = [ [ERROR_FILE_NOT_FOUND, 'ENOENT'], diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b9cced0087..f77b26427e 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -332,6 +332,27 @@ describe('Zstandard frame structure', () => { }) describe('JsonlSessionPersistence: default Zstandard encoding', () => { + it('atomically replaces a stored revision with compressed header and event frames', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('replace-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(header.id) + if (source === undefined) throw new Error('test session must be materialized') + const replacement = oneTurnLog().slice(0, 2) + + await persistence.replaceStored(source.revision, header, (async function* () { + yield* replacement + })()) + + const buffer = await readFile(logPath(root, header.cwd, header.id, 'zstd')) + expect(scanZstdFrames(buffer).frames).toHaveLength(2) + const plaintext = (await decodeCompleteFrames(buffer)).toString() + expect(scanLog(Buffer.from(plaintext)).events).toEqual(replacement) + }) + it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { const root = await freshRoot() const ctx = await mount(root) @@ -374,7 +395,8 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { '', ].join('\n')) const scanned = scanLog(Buffer.from(raw!.content)) - expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + expect(scanned.events.map(event => (event as SessionEvent).type)) + .toEqual(oneTurnLog().map(event => event.type)) }) it('readRaw rejects a present zstd artifact that carries no frame', async () => { @@ -387,6 +409,32 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => { await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) await expect(ctx.sessionPersistence.readRaw(header.id)) .rejects.toThrow('empty or header-less Zstandard session log') + await expect(ctx.sessionPersistence.load(header.id)) + .rejects.toThrow('empty or header-less Zstandard session log') + }) + + it('rejects a zero-frame artifact through an already-open stored reader', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('stored-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as JsonlSessionPersistence + const source = await persistence.openStored(header.id) + if (source === undefined) throw new Error('test session must be materialized') + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + + const read = source.readEvents() + const completion = read.completed.catch((error: unknown) => error) + const consumption = (async (): Promise => { + for await (const _event of read.events) { + // A zero-frame artifact cannot yield a logical event. + } + })().catch((error: unknown) => error) + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toMatchObject({ message: 'empty or header-less Zstandard session log' }) }) it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { @@ -727,7 +775,7 @@ describe('JsonlSessionPersistence: encoding selection', () => { '', ].join('\n')) await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/) - await expect((ctx.sessionPersistence as JsonlSessionPersistence).loadStored(loadHeader.id)) + await expect((ctx.sessionPersistence as JsonlSessionPersistence).openStored(loadHeader.id)) .rejects.toThrow(/uses \.jsonl/) await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/) }) diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 969f1f5fac..68becaec86 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -15,14 +15,16 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, - type StoredPrefix, type StoredSuffix, + type StoredEventRead, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEventType, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { - type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, + type JournalMode, openDatabase, rowToStoredMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' export { SCHEMA_VERSION } from './schema.ts' @@ -50,6 +52,27 @@ function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevi ) } +interface SqliteStoredPrefix { + readonly meta: unknown + readonly events: unknown[] + readonly revision: PersistenceRevision + readonly tornMarker?: number +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -203,9 +226,43 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers // --- PersistenceBackend hooks (the SQLite storage primitives) --- - /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { - return this.readPrefix(id, signal) + /** Open repeatable reads over one SQLite row revision. */ + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + if (row === undefined) return undefined + const revision = sqliteRevision(this.storeIdentity, row) + return { + meta: rowToStoredMeta(row), + revision, + readEvents: (options = {}): StoredEventRead => { + const completed = deferred<{ tornMarker?: number }>() + const events = (async function* (backend: SqliteSessionPersistence): AsyncIterable { + try { + const fromSeq = options.fromSeq ?? 0 + const stored = fromSeq === 0 + ? await backend.readPrefix(id, signal) + : await backend.readSuffix(id, fromSeq, signal) + if (stored === undefined || stored.revision !== revision) { + throw new SessionPersistenceRevisionConflictError( + `session "${id}" changed while reading revision ${revision}`, + ) + } + for (const event of stored.events) { + signal?.throwIfAborted() + yield event + } + completed.resolve(stored.tornMarker === undefined ? {} : { tornMarker: stored.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })(this) + return { events, completed: completed.promise } + }, + } } /** Read one row's revision without loading its events. */ @@ -222,27 +279,42 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * read scales with the suffix, not the log. Torn rows past the preserved * region are dropped, never repaired (non-mutating read). */ - async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + private async readSuffix(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() - const row = this.rowFor(id) - if (row === undefined) return undefined - const meta = rowToMeta(row) - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') - .all(id, fromSeq) as unknown as EventRow[] + this.db.exec('BEGIN') + let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined + try { + const row = this.rowFor(id) + if (row !== undefined) { + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .all(id, fromSeq) as unknown as EventRow[] + snapshot = { row, eventRows } + } + this.db.exec('COMMIT') + } catch (error: unknown) { + this.db.exec('ROLLBACK') + throw error + } signal?.throwIfAborted() + if (snapshot === undefined) return undefined + const { row, eventRows } = snapshot const { preserved } = scanRows(eventRows, fromSeq) - return { meta, events: preserved } + return { + meta: rowToStoredMeta(row), + events: preserved, + revision: sqliteRevision(this.storeIdentity, row), + } } /** - * Read a session's row + ordered events into a {@link StoredPrefix}. The + * Read a session's row and ordered events at one SQLite snapshot. The * torn-tail marker is the seq from which a never-committed tail must be deleted * (`scanRows` already returns it as `number | undefined`). */ - private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> { + private async readPrefix(id: SessionId, signal?: AbortSignal): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() @@ -268,7 +340,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers const { row, eventRows } = snapshot const { preserved, tornFrom } = scanRows(eventRows) return { - meta: rowToMeta(row), + meta: rowToStoredMeta(row), events: preserved, revision: sqliteRevision(this.storeIdentity, row), ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, @@ -337,6 +409,102 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers } } + /** Stage a streamed replacement, then compare and swap it in one transaction. */ + async replaceStored( + expectedRevision: PersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise { + await this.ready + const observed = this.rowFor(meta.id) + if (observed === undefined + || sqliteRevision(this.storeIdentity, observed) !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + if (meta.cwd !== (observed.cwd ?? undefined)) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + const staging = `format_upgrade_${randomUUID().replaceAll('-', '')}` + this.db.exec(` + CREATE TEMP TABLE ${staging} ( + seq INTEGER PRIMARY KEY, + type TEXT NOT NULL, + time INTEGER NOT NULL, + data TEXT NOT NULL, + source_event_seqs TEXT, + surface_op TEXT, + ignorable INTEGER + ) STRICT + `) + try { + const stageEvent = this.db.prepare( + `INSERT INTO ${staging} (seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + for await (const event of events) { + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + stageEvent.run(event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) + } + + this.db.exec('BEGIN IMMEDIATE') + let began = true + try { + const row = this.rowFor(meta.id) + if (row === undefined + || sqliteRevision(this.storeIdentity, row) !== expectedRevision) { + this.db.exec('ROLLBACK') + began = false + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + if (meta.cwd !== (row.cwd ?? undefined)) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + this.db.prepare('DELETE FROM events WHERE session_id = ?').run(meta.id) + this.db.prepare(` + INSERT INTO events + (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) + SELECT ?, seq, type, time, data, source_event_seqs, surface_op, ignorable + FROM ${staging} + ORDER BY seq + `).run(meta.id) + this.db.prepare(` + UPDATE sessions SET + version = ?, + created_at = ?, + cwd = ?, + parent_session = ?, + seed_length = ?, + origin = ?, + delegation_depth = ?, + agent_preset = ?, + revision = revision + 1 + WHERE id = ? + `).run( + meta.version, + meta.createdAt, + meta.cwd ?? null, + meta.parentSession ?? null, + meta.seedLength ?? null, + meta.origin ?? null, + meta.delegationDepth ?? null, + meta.agentPreset ?? null, + meta.id, + ) + this.db.exec('COMMIT') + began = false + } catch (error: unknown) { + if (began) this.db.exec('ROLLBACK') + throw error + } + } finally { + this.db.exec(`DROP TABLE IF EXISTS ${staging}`) + } + } + /** List all materialized sessions' metadata (every row is a materialized session). */ async list(signal?: AbortSignal): Promise { signal?.throwIfAborted() @@ -346,7 +514,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers .prepare('SELECT * FROM sessions') .all() as unknown as SessionRow[] signal?.throwIfAborted() - return rows.map(rowToMeta) + return rows.map(row => decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id))) } /** List metadata with a source-qualified monotonic revision per session. */ @@ -357,7 +525,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] signal?.throwIfAborted() return rows.map(row => ({ - header: rowToMeta(row), + header: decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id)), revision: SessionPersistenceRevision( `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, ), diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7402d7d4b..e436486c09 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -193,6 +193,26 @@ export function rowToMeta(row: SessionRow): SessionHeader { } } +/** + * Reconstruct parsed logical header JSON without applying the current Session + * format type. The versioned decoder owns structural migration and validation. + * @param row - stored session row. + * @returns logical header fields represented by the physical schema. + */ +export function rowToStoredMeta(row: SessionRow): unknown { + return { + version: row.version, + id: row.id, + createdAt: row.created_at, + ...row.cwd !== null ? { cwd: row.cwd } : {}, + ...row.parent_session !== null ? { parentSession: row.parent_session } : {}, + ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, + ...row.origin !== null ? { origin: row.origin } : {}, + ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, + ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, + } +} + /** * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). * @param row - the `events` table row; `data` and the surface columns hold JSON text. @@ -225,7 +245,7 @@ export function rowToEvent(row: EventRow): SessionEvent { * * @param rows - one session's event rows, ordered by seq ascending. * @param base - the seq the first row is expected to carry; `0` for a whole - * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). + * log, or the requested `fromSeq` for a seekable suffix read. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. */ diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 8f0c49e71f..83576b962b 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -9,10 +9,12 @@ import { DatabaseSync } from 'node:sqlite' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' +import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, rowToMeta, + rowToStoredMeta, scanRows, SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, type EventRow, @@ -48,6 +50,10 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () = return { ctx, dispose: () => fiber.dispose() } } +async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable { + for (const event of events) yield structuredClone(event) +} + // Run the same backend-agnostic contract as JSONL to pin identical semantics. runPersistenceContract('sqlite', async () => { const ctx = new Context() @@ -161,6 +167,53 @@ describe('scanRows', () => { }) describe('rowToMeta', () => { + it('projects every optional physical header field without current-format decoding', () => { + const row = { + id: 'stored-meta', + version: 7, + created_at: 1, + cwd: '/work', + parent_session: 'parent', + seed_length: 3, + origin: 'subagent' as const, + incarnation: 'stored-meta', + revision: 1, + delegation_depth: 2, + agent_preset: 'minimal', + } + expect(rowToStoredMeta(row)).toEqual({ + version: 7, + id: 'stored-meta', + createdAt: 1, + cwd: '/work', + parentSession: 'parent', + seedLength: 3, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + }) + expect(rowToStoredMeta({ + ...row, + cwd: null, + parent_session: null, + seed_length: null, + origin: null, + delegation_depth: null, + agent_preset: null, + })).toEqual({ version: 7, id: 'stored-meta', createdAt: 1 }) + expect(rowToMeta(row)).toEqual({ + version: 7, + id: 'stored-meta', + createdAt: 1, + cwd: '/work', + parentSession: 'parent', + seedLength: 3, + origin: 'subagent', + delegationDepth: 2, + agentPreset: 'minimal', + }) + }) + it('restores optional origin metadata', () => { expect(rowToMeta({ id: 'with-origin', @@ -597,19 +650,175 @@ describe('SqliteSessionPersistence: durability and crash semantics', () => { await b.dispose() }) - it('binds a full stored prefix to the same revision as a lightweight read', async () => { + it('binds a stored source to the same revision as a lightweight read', async () => { const b = await backend() const m = meta('stored-prefix-revision') await b.ctx.sessionPersistence.create(m) await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence - const stored = await persistence.loadStored(m.id) + const stored = await persistence.openStored(m.id) expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id)) expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined() await b.dispose() }) + it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => { + const { ctx, dispose } = await backend() + const m = meta('stored-reader-conflict') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as SqliteSessionPersistence + const changed = await persistence.openStored(m.id) + if (changed === undefined) throw new Error('test session must be materialized') + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } }, + ]) + const changedRead = changed.readEvents() + const changedCompletion = changedRead.completed.catch((error: unknown) => error) + await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })()) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + + const removed = await persistence.openStored(m.id) + if (removed === undefined) throw new Error('test session must remain materialized') + const internals = persistence as unknown as { db: DatabaseSync } + internals.db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + const removedRead = removed.readEvents({ fromSeq: 1 }) + const removedCompletion = removedRead.completed.catch((error: unknown) => error) + await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })()) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError) + await dispose() + }) + + it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => { + const { ctx, dispose } = await backend() + const persistence = ctx.sessionPersistence as SqliteSessionPersistence + const internals = persistence as unknown as { + db: DatabaseSync + readPrefix(id: SessionId): Promise + readSuffix(id: SessionId, fromSeq: number): Promise + } + expect(await internals.readPrefix(SessionId('missing-prefix'))).toBeUndefined() + expect(await internals.readSuffix(SessionId('missing-suffix'), 1)).toBeUndefined() + + const m = meta('suffix-rollback') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const prepare = internals.db.prepare.bind(internals.db) + const spy = vi.spyOn(internals.db, 'prepare').mockImplementation((sql) => { + if (sql.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure') + return prepare(sql) + }) + await expect(internals.readSuffix(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure') + spy.mockRestore() + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await dispose() + }) + + it('atomically replaces one exact revision and rejects a stale replacement', async () => { + const b = await backend() + const m = meta('format-replace') + const original = [ + ...oneTurnLog(), + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, original) + const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog())) + const replaced = await persistence.openStored(m.id) + if (replaced === undefined) throw new Error('replacement must preserve the session') + expect(replaced.revision).not.toBe(source.revision) + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents(original)), + ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + await b.dispose() + }) + + it('rejects replacement identity changes before and during the transaction', async () => { + const first = await backend() + const m = meta('format-replace-identity', '/work') + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = first.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + + await expect(persistence.replaceStored( + source.revision, + { ...m, cwd: '/other' }, + replacementEvents(oneTurnLog()), + )).rejects.toThrow(/changes its stored identity/) + + const internals = persistence as unknown as { db: DatabaseSync } + const changesDuringStaging = (async function* (): AsyncIterable { + yield* oneTurnLog() + internals.db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id) + })() + await expect(persistence.replaceStored(source.revision, m, changesDuringStaging)) + .rejects.toThrow(/changes its stored identity/) + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await first.dispose() + }) + + it('rejects a revision change that occurs while replacement events are staged', async () => { + const mounted = await backend() + const m = meta('format-replace-staging-race', '/work') + await mounted.ctx.sessionPersistence.create(m) + await mounted.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = mounted.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const internals = persistence as unknown as { db: DatabaseSync } + const changesDuringStaging = (async function* (): AsyncIterable { + yield* oneTurnLog() + internals.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id) + })() + + await expect(persistence.replaceStored(source.revision, m, changesDuringStaging)) + .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) + expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + .toBe(oneTurnLog().length) + await mounted.dispose() + }) + + it('rolls back the complete replacement when the transaction fails after it begins', async () => { + const b = await backend() + const m = meta('format-replace-rollback') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence + const source = await persistence.openStored(m.id) + if (source === undefined) throw new Error('test session must be materialized') + const db = (persistence as unknown as { db: DatabaseSync }).db + db.exec(` + CREATE TEMP TRIGGER fail_format_replace + BEFORE UPDATE ON sessions + BEGIN + SELECT RAISE(ABORT, 'simulated format replacement failure'); + END + `) + + await expect( + persistence.replaceStored(source.revision, m, replacementEvents([])), + ).rejects.toThrow(/simulated format replacement failure/) + db.exec('DROP TRIGGER fail_format_replace') + + expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog()) + await b.dispose() + }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { const path = await freshDbPath() const m = meta('recreated-revision') diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index eb5f9714c4..0fa2bf1df9 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -9,15 +9,22 @@ import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, - KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, snapshotJsonValue, - snapshotSessionEvent, } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection, SessionLocation } from './index.ts' +import type { SessionInspection } from './index.ts' +import { + decodeStoredSession, + SessionFormatUnsupportedError, +} from './format-decoder.ts' +import type { + DecodedSession, + StoredSessionSource, +} from './format-decoder.ts' +import { SessionPersistenceRevisionConflictError } from './revision.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -44,42 +51,6 @@ export class SessionPersistenceCorruptionError extends Error { } } -/** - * The stored log is intact but this runtime cannot faithfully interpret it: - * the header carries an unsupported format version, or an event's type is - * unknown to this build and the event is not marked ignorable. Distinct from - * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log - * remains readable at {@link location} when the backend keeps one artifact - * per session. - */ -export class SessionFormatUnsupportedError extends Error { - /** - * @param message - stable reason the log cannot be interpreted, already - * including the raw-log path when one exists. - * @param location - the backend's artifact location, when one exists. - */ - constructor(message: string, readonly location?: SessionLocation) { - super(message) - this.name = 'SessionFormatUnsupportedError' - } -} - -/** - * Direction-aware refusal text for a stored session whose format version this - * build does not read. Shared by the coordinator's load-time check and by - * backends that must refuse BEFORE decoding version-dependent structure (a - * future format may not satisfy today's structural checks at all, and the - * user must see "upgrade the harness", never "corrupt"). - * @param id - the stored session id, for message context. - * @param version - the stored format version. - * @returns the stable refusal text, without a raw-log path suffix. - */ -export function sessionFormatVersionRefusal(id: string, version: number): string { - return version > SESSION_FORMAT_VERSION - ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` - : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` -} - /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -88,32 +59,6 @@ export interface PersistenceCoordinatorOptions { readonly writeBatchMaxDelayMs: number } -/** - * A stored session's header, valid contiguous event prefix, source-qualified - * revision, and optional opaque torn-tail marker. The revision identifies the - * exact detached prefix. The coordinator only checks marker presence and - * returns its value to {@link PersistenceBackend.commitRepair}; each backend - * owns the marker type. - */ -export interface StoredPrefix { - meta: SessionHeader - events: SessionEvent[] - /** Revision observed for exactly this detached prefix. */ - revision: SessionPersistenceRevision - tornMarker?: TornMarker -} - -/** - * A stored session's header plus the events at or past a requested seq — the - * return shape of the optional seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no - * torn marker: there is nothing to repair. - */ -export interface StoredSuffix { - meta: SessionHeader - events: SessionEvent[] -} - /** * The storage contract between {@link PersistenceCoordinator} and a concrete * backend: the minimal set of durable primitives the orchestration calls. A @@ -121,27 +66,21 @@ export interface StoredSuffix { * coordinator supplies everything else (buffering, serialization, cursors, * adoption, crash repair sequencing, dispose quiescence). * - * @typeParam TornMarker - the backend's opaque torn-tail repair token (see - * {@link StoredPrefix}). The coordinator treats it as fully opaque. + * @typeParam TornMarker - the backend's opaque torn-tail repair token returned + * after a complete event read. The coordinator treats it as fully opaque. */ export interface PersistenceBackend { /** Human-readable backend name, used in the dispose-failure AggregateError. */ readonly name: string /** - * Read a stored prefix by id, scanning every backend storage scope. Returns - * `undefined` if no stored artifact exists. Returned metadata must identify - * `id` before repair or state publication. Used by resume/load, live adoption, - * and — via `!== undefined` — the create-collision probe. The returned - * `tornMarker` is present iff there is a torn tail to truncate. Every header - * and event graph must be fresh, mutually unaliased, and unretained by the - * backend because preparation freezes and publishes them in place. The - * returned revision must identify exactly those values and use the same - * representation as {@link readStoredRevision}. + * Open repeatable access to one stored revision by id, scanning every backend + * storage scope. Returns `undefined` if no artifact exists. Each event reader + * reproduces this revision or rejects when a concurrent writer changed it. * @param id - persisted session id to resolve. * @param signal - optional cancellation for backend read work. */ - loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> + openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> /** * Read the current source-qualified revision for one stored session without @@ -151,30 +90,6 @@ export interface PersistenceBackend { */ readStoredRevision(id: SessionId, signal?: AbortSignal): Promise - /** - * Optional seek-capable suffix read behind the service's `readFrom`: return - * the header plus the stored events with `seq >= fromSeq` without reading - * the whole log. A backend whose medium can address events by seq (SQLite) - * implements this so `readFrom` scales with the suffix; sequential backends - * omit it and the coordinator falls back to {@link loadStored} plus a - * forward skip. Non-mutating (no truncation, no closers). Validation of the - * region strictly below `fromSeq` is limited to seq contiguity — the - * service contract scopes this read to the suffix — unless that suffix - * contains a supported legacy shape whose normalization needs earlier - * message-identity facts, in which case the coordinator falls back - * to the complete stored prefix. - * Unknown-type refusal follows the same suffix scope: a seek-capable - * backend's `readFrom` checks only the returned suffix, while the - * sequential fallback parses the whole artifact and refuses on an unknown - * required event anywhere in it — over-refusal on the sequential side is - * accepted rather than widening the seek read. - * @param id - persisted session id to resolve. - * @param fromSeq - first event seq to include (non-negative safe integer, - * validated by the coordinator before this hook runs). - * @param signal - optional cancellation for backend read work. - */ - loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise - /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -192,20 +107,26 @@ export interface PersistenceBackend { */ commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise + /** + * Atomically replace one exact stored revision with a complete current log. + * The backend checks revision and storage identity in the same exclusive file + * operation or database transaction that commits the replacement. + * @param expectedRevision - exact source revision decoded by the caller. + * @param meta - complete current-format header. + * @param events - complete current-format event stream. + */ + replaceStored( + expectedRevision: SessionPersistenceRevision, + meta: SessionHeader, + events: AsyncIterable, + ): Promise + /** * List all stored (materialized) sessions' metadata. * @param signal - optional cancellation for backend listing work. */ list(signal?: AbortSignal): Promise - /** - * Optional side-effect-free artifact locator, used to point refusal - * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. - * Backends without one artifact per session omit it or return `undefined`. - * @param meta - the header whose artifact is requested. - */ - locate?(meta: SessionHeader): SessionLocation | undefined - /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -245,6 +166,7 @@ interface PreparedSessionSource { readonly inspection: SessionInspection readonly session: Session readonly revision: SessionPersistenceRevision + readonly sourceVersion: number /** Session length after constructor-owned seed markers were appended. */ readonly sessionLength: number readonly tornMarker: TornMarker | undefined @@ -270,7 +192,7 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio }) } -/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */ +/** Reject obsolete v0 event records before a live writer persists them. */ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { const legacyType: string = 'request/header-delta' const legacy = events.find(event => event.type === legacyType) @@ -289,287 +211,29 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): } } -/** Return an object record without widening arrays into message payloads. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - -/** Whether a record contains every required key and no key outside the optional extension set. */ -function hasOnlyKeys( - record: Record, - required: readonly string[], - optional: readonly string[] = [], -): boolean { - const allowed = [...required, ...optional] - return Object.keys(record).every(key => allowed.includes(key)) - && required.every(key => Object.hasOwn(record, key)) -} - -type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] - -/** Mint the stable import identity for a message persisted before identities existed. */ -function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { - return `legacy-message:${id}:${seq}` as PersistedMessageId -} - -/** Read a replacement target while leaving malformed surface metadata to the session validator. */ -function replacementStart(event: SessionEvent): number | undefined { - const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) - return op?.['op'] === 'replace' && typeof op['start'] === 'number' - ? op['start'] - : undefined -} - -/** Whether one suffix event needs facts available only from the preceding stored prefix. */ -function needsLegacyPrefix(event: SessionEvent): boolean { - const data = asRecord(event.data) - const legacySteeringType: string = 'steering/message' - if (event.type === legacySteeringType) return true - if (data === undefined) return false - switch (event.type) { - case 'user/message': - return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') - case 'assistant/message': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') - case 'tool/result': - return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') - default: - return false +/** Materialize one decoded event read and observe its physical EOF metadata. */ +async function collectDecodedEvents( + read: DecodedSession, +): Promise<{ events: SessionEvent[]; tornMarker: TornMarker | undefined }> { + const events: SessionEvent[] = [] + try { + for await (const event of read.events) events.push(event) + } catch (error: unknown) { + await read.completed.catch(() => undefined) + throw error } + const { tornMarker } = await read.completed + return { events, tornMarker } } -/** Upgrade the removed steering surface event into its current user-message equivalent. */ -function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { - const legacyType: string = 'steering/message' - if (event.type !== legacyType) return event - const data = asRecord(event.data) - if (data === undefined) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const wrapped = asRecord(data['message']) - if (wrapped !== undefined && Number.isSafeInteger(data['turn']) - && hasOnlyKeys(data, ['turn', 'message'])) { - return { ...event, type: 'user/message', data: wrapped } as SessionEvent - } - if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { - throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) - } - const { turn: _turn, ...message } = data +/** Yield an immutable event array as one replacement stream. */ +function eventStream(events: readonly SessionEvent[]): AsyncIterable { return { - ...event, - type: 'user/message', - data: { - ...message, - id: legacyMessageId(id, event.seq), - role: 'user', + [Symbol.asyncIterator]() { + const iterator = events[Symbol.iterator]() + return { next: () => Promise.resolve(iterator.next()) } }, - } as SessionEvent -} - -/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */ -function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/start') return event - const data = asRecord(event.data) - if (data === undefined || !Object.hasOwn(data, 'trigger')) return event - const trigger = asRecord(data['trigger']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'trigger']) - || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) } - return { ...event, data: { turn: data['turn'] } } as SessionEvent -} - -/** Upgrade an obsolete turn ending while preserving the latest-master envelope. */ -function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { - if (event.type !== 'turn/end') return event - const data = asRecord(event.data) - /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */ - if (data === undefined) return event - const malformed = (): never => { - throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) - } - const reason = asRecord(data['reason']) - if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 - || !hasOnlyKeys(data, ['turn', 'reason']) - || reason === undefined || typeof reason['kind'] !== 'string') return malformed() - - let currentReason: Record | undefined - switch (reason['kind']) { - case 'completed': - case 'blocked': - case 'max-tokens': - case 'interrupted': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - return event - case 'aborted': - if (Object.hasOwn(reason, 'reason')) return event - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } - break - case 'disposed': - if (!hasOnlyKeys(reason, ['kind'])) return malformed() - currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } - break - case 'error': { - if (Object.hasOwn(reason, 'error')) return event - if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() - const failure = asRecord(reason['failure']) - if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) - && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) - && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' - && (failure['status'] === undefined || typeof failure['status'] === 'number') - && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') - && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { - currentReason = { kind: 'error', error: failure } - break - } - const messageKeys = reason['code'] === undefined - ? ['kind', 'step', 'message'] - : ['kind', 'step', 'message', 'code'] - if (!hasOnlyKeys(reason, messageKeys) - || typeof reason['message'] !== 'string' - || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() - currentReason = { - kind: 'error', - error: { - message: reason['message'], - code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', - }, - } - break - } - default: - return event - } - - return { - ...event, - data: { - ...data, - reason: currentReason, - }, - } as SessionEvent -} - -/** - * Upgrade one pre-identity message event into the current wrapper shape. - * Current-looking malformed events remain untouched so validation rejects them - * instead of disguising corruption as legacy data. - */ -function migrateLegacyMessageEvent( - event: SessionEvent, - id: SessionId, - messageIds: ReadonlyMap, -): SessionEvent { - const data = asRecord(event.data) - if (data === undefined) return event - switch (event.type) { - case 'user/message': { - if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') - || Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event - return { - ...event, - data: { - ...data, - id: legacyMessageId(id, event.seq), - role: 'user', - }, - } as SessionEvent - } - case 'assistant/message': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event - const { content, provenance, ...eventData } = data - return { - ...event, - data: { - ...eventData, - message: { - id: legacyMessageId(id, event.seq), - role: 'assistant', - content, - source: { - ...asRecord(provenance), - kind: 'model', - }, - }, - }, - } as SessionEvent - } - case 'tool/result': { - if (Object.hasOwn(data, 'message') - || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') - || !Object.hasOwn(data, 'isError')) return event - const { callId, content, isError, ...eventData } = data - const inheritedId = replacementStart(event) - return { - ...event, - data: { - ...eventData, - message: { - id: inheritedId === undefined - ? legacyMessageId(id, event.seq) - : messageIds.get(inheritedId), - role: 'user', - content: [{ - type: 'tool-result', - toolCallId: callId, - content, - isError, - }], - source: { - kind: 'tool', - callId, - }, - }, - }, - } as SessionEvent - } - default: - return event - } -} - -/** Read the identified message carried by one validated current event. */ -function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { - const data = asRecord(event.data) - const message = event.type === 'user/message' ? data : asRecord(data?.['message']) - return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined -} - -/** Materialize stored events as upgraded, validated snapshots with immutable messages. */ -function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - return events.map((event) => { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - const messageId = eventMessageId(snapshot) - if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) - return snapshot - }) -} - -/** Upgrade and validate an exclusively owned backend result without copying it. */ -function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] { - assertSupportedEvents(events, id) - const messageIds = new Map() - for (const [index, event] of events.entries()) { - const migratedStart = migrateLegacyTurnStartEvent(event, id) - const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id) - const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id) - const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds)) - events[index] = adopted - const messageId = eventMessageId(adopted) - if (messageId !== undefined) messageIds.set(adopted.seq, messageId) - } - return events } /** @@ -650,7 +314,7 @@ export class PersistenceCoordinator { // A persisted artifact under this id (in ANY scope) blocks creation: load/ // resume identify a session by id alone, so a second artifact would make // resume nondeterministic. - if (await this.backend.loadStored(meta.id) !== undefined) { + if (await this.backend.openStored(meta.id) !== undefined) { throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`) } // Pure lazy: record intent only. No artifact until the first append. @@ -821,9 +485,8 @@ export class PersistenceCoordinator { /** * Read the stored events from `fromSeq` onward, detached and non-mutating * (the read-from-seq primitive behind the service's `readFrom`). Runs on - * the same per-id chain as writes; a backend with the seek-capable - * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix, - * every other backend reads its stored prefix and skips forward here. + * the same per-id chain as writes. The format decoder requests a backend + * suffix only when every selected transform can start at `fromSeq`. * @param id - persisted session to read. * @param fromSeq - first event seq to include; a non-negative safe integer. * @param signal - optional cancellation for queued and backend read work. @@ -843,90 +506,64 @@ export class PersistenceCoordinator { fromSeq: number, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - if (this.backend.loadStoredFrom !== undefined) { - let suffix: StoredSuffix | undefined + for (;;) { + signal?.throwIfAborted() + const stored = await this.backend.openStored(id, signal) + signal?.throwIfAborted() + if (stored === undefined) throw new Error(`session "${id}" not found`) try { - suffix = await this.backend.loadStoredFrom(id, fromSeq, signal) + const current = decodeStoredSession(stored, id, fromSeq) + const { events } = await collectDecodedEvents(current) + signal?.throwIfAborted() + return { meta: structuredClone(current.meta), events } } catch (error: unknown) { - if (signal?.aborted) signal.throwIfAborted() + signal?.throwIfAborted() + if (error instanceof SessionPersistenceRevisionConflictError) continue throw error } - signal?.throwIfAborted() - if (suffix === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, suffix.meta) - this.assertVersion(suffix.meta) - if (suffix.events.some(needsLegacyPrefix)) { - const whole = await this.readStoredPrefix(id, signal) - return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } - } - const events = snapshotStoredEvents(suffix.events, id) - this.assertEventsSupported(suffix.meta, events) - return { meta: structuredClone(suffix.meta), events } - } - const whole = await this.readStoredPrefix(id, signal) - // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. - return { meta: whole.meta, events: whole.events.slice(fromSeq) } - } - - /** Read one detached physical prefix without logical recovery or caching. */ - private async readStoredPrefix( - id: SessionId, - signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - signal?.throwIfAborted() - const stored = await this.backend.loadStored(id, signal) - signal?.throwIfAborted() - if (stored === undefined) throw new Error(`session "${id}" not found`) - this.assertStoredId(id, stored.meta) - this.assertVersion(stored.meta) - const events = snapshotStoredEvents(stored.events, id) - this.assertEventsSupported(stored.meta, events) - return { - meta: structuredClone(stored.meta), - events, } } /** Read, repair in memory, validate, and freeze one cold source once. */ private async prepareCore(id: SessionId): Promise> { - const stored = await this.backend.loadStored(id) - if (stored === undefined) throw new Error(`session "${id}" not found`) - try { - const { meta, events, revision, tornMarker } = stored - this.assertStoredId(id, meta) - this.assertVersion(meta) - const storedEvents = adoptStoredEvents(events, id) - this.assertEventsSupported(meta, storedEvents) + for (;;) { + const stored = await this.backend.openStored(id) + if (stored === undefined) throw new Error(`session "${id}" not found`) + try { + const current = decodeStoredSession(stored, id) + const { events: storedEvents, tornMarker } = await collectDecodedEvents(current) - // Preserve complete interrupted events and synthesize only missing closers. - const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) - const balanced = [...storedEvents, ...closers] - const session = this.ctx.sessions.prepare(id, { - seed: balanced, - meta, - seedSource: 'persistence', - }) - const inspection: SessionInspection = Object.freeze({ - meta: session.header, - events: Object.freeze(balanced), - }) - return { - inspection, - session, - revision, - sessionLength: session.events.length, - tornMarker, - closers, + // Preserve complete interrupted events and synthesize only missing closers. + const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) + const balanced = [...storedEvents, ...closers] + const session = this.ctx.sessions.prepare(id, { + seed: balanced, + meta: current.meta, + seedSource: 'persistence', + }) + const inspection: SessionInspection = Object.freeze({ + meta: session.header, + events: Object.freeze(balanced), + }) + return { + inspection, + session, + revision: current.revision, + sourceVersion: current.sourceVersion, + sessionLength: session.events.length, + tornMarker, + closers, + } + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + // An unsupported format is a refusal over an intact log, not damage — + // surface it unwrapped so callers can point at the raw artifact. + if (error instanceof SessionFormatUnsupportedError) throw error + throw new SessionPersistenceCorruptionError( + `stored session "${id}" failed validation: ${String(error)}`, + { cause: error }, + ) } - } catch (error: unknown) { - // An unsupported format is a refusal over an intact log, not damage — - // surface it unwrapped so callers can point at the raw artifact. - if (error instanceof SessionFormatUnsupportedError) throw error - throw new SessionPersistenceCorruptionError( - `stored session "${id}" failed validation: ${String(error)}`, - { cause: error }, - ) } } @@ -941,6 +578,19 @@ export class PersistenceCoordinator { throw new Error(`session "${id}" already has a live persistence owner`) } if (!await this.isPreparedSourceCurrent(source)) return undefined + if (source.sourceVersion !== SESSION_FORMAT_VERSION) { + try { + await this.backend.replaceStored( + source.revision, + source.inspection.meta, + eventStream(source.inspection.events), + ) + } catch (error: unknown) { + if (!(error instanceof SessionPersistenceRevisionConflictError)) throw error + } + // A commit has a new revision; a conflict names a different source. + return undefined + } if (source.tornMarker !== undefined || source.closers.length > 0) { await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers) // The repair changed the durable revision. Reload the exact committed @@ -1043,44 +693,6 @@ export class PersistenceCoordinator { } } - private assertVersion(meta: SessionHeader): void { - if (meta.version === SESSION_FORMAT_VERSION) return - throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) - } - - /** - * Refuse a log containing an event type this build does not know, unless the - * writer marked the event ignorable: an unrecognized required event may - * change how the rest of the log must be interpreted, so silently skipping - * it would reconstruct a wrong session (the envelope contract on - * `SessionEvent.ignorable`). Runs on NORMALIZED events — after - * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes - * this build still reads and rejected the ones it does not, so those keep - * their specific diagnostics. - */ - private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { - for (const event of events) { - if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue - throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) - } - } - - /** Build a format refusal that points at the raw artifact when the backend has one. */ - private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { - const location = this.backend.locate?.(meta) - return new SessionFormatUnsupportedError( - location === undefined ? reason : `${reason} (raw log: ${location.path})`, - location, - ) - } - - /** Reject backend metadata that is not bound to the requested session id. */ - private assertStoredId(id: SessionId, meta: SessionHeader): void { - if (meta.id !== id) { - throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`) - } - } - // --- write path (session/event → flush drain) --- private installWritePath(): void { @@ -1213,11 +825,19 @@ export class PersistenceCoordinator { */ private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise { if (cursor === 0) return true - const stored = await this.backend.loadStored(id) - /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ - if (stored === undefined) return false - this.assertStoredId(id, stored.meta) - return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor)) + for (;;) { + const stored = await this.backend.openStored(id) + /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ + if (stored === undefined) return false + try { + const current = decodeStoredSession(stored, id) + const { events } = await collectDecodedEvents(current) + return seedCoversPrefix(seed, events.slice(0, cursor)) + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + throw error + } + } } /** @@ -1271,13 +891,18 @@ export class PersistenceCoordinator { // case 2/3: resolve the id once across storage, then let adoption reject a // cwd mismatch before repair or state publication. - const live = await this.backend.loadStored(id) - if (live !== undefined) { + for (;;) { + const live = await this.backend.openStored(id) + if (live === undefined) break // Do NOT route through cold preparation: that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. - await this.adoptLivePrefix(session, seed, live) - return + try { + if (await this.adoptLivePrefix(session, seed, live)) return + } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) continue + throw error + } } // case 4: a genuinely new session. Register its meta (lazy), then persist its @@ -1298,28 +923,39 @@ export class PersistenceCoordinator { * the live Session is still the authority), bind ownership, and persist the * live suffix that was ahead of the stored prefix. */ - private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix): Promise { - const { meta, events, tornMarker } = stored - this.assertStoredId(session.header.id, meta) - if (meta.cwd !== session.header.cwd) { - throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) + private async adoptLivePrefix( + session: Session, + seed: readonly SessionEvent[], + stored: StoredSessionSource, + ): Promise { + const current = decodeStoredSession(stored, session.header.id) + if (current.meta.cwd !== session.header.cwd) { + throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(current.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } - this.assertVersion(meta) - const storedEvents = snapshotStoredEvents(events, session.header.id) - this.assertEventsSupported(meta, storedEvents) + const { events: storedEvents, tornMarker } = await collectDecodedEvents(current) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } + if (current.sourceVersion !== SESSION_FORMAT_VERSION) { + await this.backend.replaceStored( + current.revision, + current.meta, + eventStream(storedEvents), + ) + // Reopen after the commit because it produced a new source revision. + return false + } // Truncate-only repair (no closers): the open turn is NOT closed here. - if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) + if (tornMarker !== undefined) await this.backend.commitRepair(current.meta, tornMarker, []) this.states.set(session.header.id, { - meta: { ...meta }, + meta: { ...current.meta }, cursor: storedEvents.length, materialized: true, owner: session, }) const suffix = seed.slice(storedEvents.length) if (suffix.length > 0) await this.appendCore(session.header.id, suffix) + return true } private async flush(session: Session): Promise { diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts new file mode 100644 index 0000000000..2fbc45ccd3 --- /dev/null +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -0,0 +1,480 @@ +/** + * Static Session format decoding from backend-owned JSON records to the + * current durable header and event types. + * @module @deepseek-ai/dsh-session-persistence/format-decoder + */ + +import { + adoptSessionEvent, + KNOWN_SESSION_EVENT_TYPES, + SESSION_FORMAT_VERSION, + Session, + SessionId, + snapshotJsonValue, +} from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { + unversionedFormatCompatibility, +} from './format-v0-compat.ts' +import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' +import type { SessionLocation } from './index.ts' +import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' +import type { SessionPersistenceRevision } from './revision.ts' + +/** Stable facts available to one format step invocation. */ +export interface SessionFormatContext { + /** Session identity read from the source header. */ + readonly sessionId: SessionId +} + +/** One static adjacent-version transform in the durable format decoder. */ +export interface SessionFormatStep { + /** Input Session format version. */ + readonly from: number + /** Output Session format version; must equal `from + 1`. */ + readonly to: number + /** + * Transform and validate the header fields understood by this step. + * @param meta - detached input header for {@link from}. + * @param context - stable session identity. + * @returns detached header JSON carrying {@link to}. + */ + migrateHeader(meta: unknown, context: SessionFormatContext): unknown + /** + * Lazily transform and validate events understood by this step. + * @param events - detached input records in durable sequence order. + * @param context - stable session identity. + * @returns a lazy output stream in the next format. + */ + migrateEvents(events: AsyncIterable, context: SessionFormatContext): AsyncIterable +} + +/** Options for one physical event read. */ +export interface StoredEventReadOptions { + /** First physical event sequence to request. */ + readonly fromSeq?: number +} + +/** Completion metadata produced after a physical event stream reaches EOF. */ +export interface StoredEventReadCompletion { + /** Backend-owned token for a recoverable physical tail. */ + readonly tornMarker?: TornMarker +} + +/** One revision-bound physical event stream. */ +export interface StoredEventRead { + /** Parsed JSON records from the exact source revision. */ + readonly events: AsyncIterable + /** Resolves only after the stream reaches EOF at the same revision. */ + readonly completed: Promise> +} + +/** Repeatable access to one stored header and exact durable revision. */ +export interface StoredSessionSource { + /** Parsed header JSON; format validation belongs to the decoder. */ + readonly meta: unknown + /** Exact backend revision every event read must reproduce or reject. */ + readonly revision: SessionPersistenceRevision + /** Raw artifact location used to enrich unsupported-format diagnostics. */ + readonly location?: SessionLocation + /** + * Open a new event read bound to {@link revision}. A concurrent replacement + * rejects the read instead of returning events from another revision. + * @param options - optional suffix request. + * @returns one independently consumable physical event read. + */ + readEvents(options?: StoredEventReadOptions): StoredEventRead +} + +/** One decoded current-format read bound to an exact stored revision. */ +export interface DecodedSession { + /** Validated current-format header. */ + readonly meta: SessionHeader + /** Version observed before any format step ran. */ + readonly sourceVersion: number + /** Exact backend revision represented by this source. */ + readonly revision: SessionPersistenceRevision + /** Validated current-format events at or past the requested sequence. */ + readonly events: AsyncIterable + /** Completion metadata from the physical read supplying the events. */ + readonly completed: Promise> +} + +/** + * The stored log is intact but this runtime cannot faithfully interpret its + * format version or required event vocabulary. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable refusal reason, including the raw location when available. + * @param location - backend artifact location when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + +/** + * Direction-aware refusal text for a stored format version this build cannot + * decode. + * @param id - stored session identity. + * @param version - stored format version. + * @returns stable refusal text without a raw-location suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} + +function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap { + const byFrom = new Map() + for (const step of steps) { + if (!Number.isSafeInteger(step.from) || step.from < 0 || step.to !== step.from + 1) { + throw new TypeError(`Session format step must be an adjacent non-negative version, got v${step.from} -> v${step.to}`) + } + if (byFrom.has(step.from)) { + throw new TypeError(`duplicate Session format step from v${step.from}`) + } + if (step.to > SESSION_FORMAT_VERSION) { + throw new TypeError(`Session format step v${step.from} -> v${step.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) + } + byFrom.set(step.from, step) + } + for (const first of byFrom.values()) { + for (let version = first.from; version < SESSION_FORMAT_VERSION; version++) { + if (!byFrom.has(version)) { + throw new TypeError( + `Session format registry has an incomplete path from v${first.from} to v${SESSION_FORMAT_VERSION}: missing v${version} -> v${version + 1}`, + ) + } + } + } + return byFrom +} + +const STEP_BY_FROM = buildStepIndex(SESSION_FORMAT_STEPS) + +interface DecodedHeader { + readonly meta: SessionHeader + readonly sourceVersion: number + readonly steps: readonly SessionFormatStep[] + readonly unversionedCompatibility?: UnversionedFormatCompatibility +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +interface StoredHeaderSource { + readonly meta: unknown + readonly location?: SessionLocation +} + +function unsupported( + source: StoredHeaderSource, + reason: string, +): SessionFormatUnsupportedError { + const location = source.location + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) +} + +function readSourceHeader( + source: StoredHeaderSource, + expectedId: SessionId, +): { meta: Record; version: number; id: SessionId } { + const snapshot = snapshotJsonValue(source.meta) + const meta = asRecord(snapshot) + if (meta === undefined) throw new Error('stored session header is not a lossless JSON record') + if (!Number.isSafeInteger(meta['version'])) { + throw new Error(`stored session header has invalid format version ${String(meta['version'])}`) + } + const version = meta['version'] as number + if (version > SESSION_FORMAT_VERSION) { + throw unsupported(source, sessionFormatVersionRefusal(String(meta['id']), version)) + } + if (typeof meta['id'] !== 'string') throw new Error('stored session header has no string id') + const id = SessionId(meta['id']) + if (id !== expectedId) { + throw new Error(`stored session identity mismatch: requested "${expectedId}", header contains "${id}"`) + } + return { meta, version, id } +} + +function planSteps( + source: StoredHeaderSource, + id: SessionId, + fromVersion: number, +): readonly SessionFormatStep[] { + const steps: SessionFormatStep[] = [] + for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) { + const step = STEP_BY_FROM.get(version) + if (step === undefined) { + throw unsupported( + source, + `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`, + ) + } + steps.push(step) + } + return steps +} + +function decodeHeader( + source: StoredHeaderSource, + expectedId: SessionId, +): DecodedHeader { + const stored = readSourceHeader(source, expectedId) + const steps = planSteps(source, stored.id, stored.version) + let meta: unknown = stored.meta + for (const step of steps) { + const context: SessionFormatContext = { sessionId: stored.id } + try { + meta = snapshotJsonValue(step.migrateHeader(meta, context)) + } catch (error: unknown) { + throw new Error( + `session "${stored.id}" header migration v${step.from} -> v${step.to} failed`, + { cause: error }, + ) + } + const record = asRecord(meta) + const actual = record?.['version'] + if (actual !== step.to) { + throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) + } + if (record === undefined + || record['id'] !== stored.id + || record['cwd'] !== stored.meta['cwd']) { + throw new Error(`Session format step v${step.from} -> v${step.to} changed session storage identity`) + } + } + const current = Session.create(stored.id, undefined, meta as SessionHeader).header + const compatibility = unversionedFormatCompatibility(stored.version) + return { + meta: current, + sourceVersion: stored.version, + steps, + ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }), + } +} + +/** + * Decode one stored header without opening its event log. Listing uses the + * same static format path as full Session reads. + * @param meta - parsed backend header JSON. + * @param expectedId - identity selected by the backend or caller. + * @param location - optional raw artifact location for refusal diagnostics. + * @returns the validated current-format header. + */ +export function decodeStoredSessionHeader( + meta: unknown, + expectedId: SessionId, + location?: SessionLocation, +): SessionHeader { + return decodeHeader({ meta, ...location === undefined ? {} : { location } }, expectedId).meta +} + +function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent { + const snapshot = snapshotJsonValue(value) + const event = asRecord(snapshot) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} + +function assertCurrentEventSupported( + source: StoredSessionSource, + meta: SessionHeader, + event: SessionEvent, +): void { + const legacyType: string = 'request/header-delta' + if (event.type === legacyType) { + throw new Error(`session "${meta.id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) + } + const legacyModeType: string = 'mode/set' + if (event.type === legacyModeType) { + throw new Error(`session "${meta.id}" contains unsupported legacy mode/set event at seq ${event.seq}`) + } + if (event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') { + throw new Error(`session "${meta.id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) + } + if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return + throw unsupported( + source, + `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + ) +} + +async function* decodeCurrentEvents( + source: StoredSessionSource, + meta: SessionHeader, + events: AsyncIterable, + expectedSeq: number, +): AsyncIterable { + let nextSeq = expectedSeq + for await (const raw of events) { + const event = assertCurrentEnvelope(raw, meta.id) + if (event.seq !== nextSeq) { + throw new Error(`session "${meta.id}" event seq mismatch: expected ${nextSeq}, got ${event.seq}`) + } + const current = adoptSessionEvent(event) + assertCurrentEventSupported(source, meta, current) + nextSeq += 1 + yield current + } +} + +function transformEvents( + events: AsyncIterable, + steps: readonly SessionFormatStep[], + id: SessionId, +): AsyncIterable { + let transformed = events + for (const step of steps) { + const input = transformed + const context: SessionFormatContext = { sessionId: id } + transformed = (async function* (): AsyncIterable { + try { + yield* step.migrateEvents(input, context) + } catch (error: unknown) { + throw new Error( + `session "${id}" event migration v${step.from} -> v${step.to} failed`, + { cause: error }, + ) + } + })() + } + return transformed +} + +async function* snapshotStoredEvents( + events: AsyncIterable, + id: SessionId, +): AsyncIterable { + for await (const event of events) { + const snapshot = snapshotJsonValue(event) + if (snapshot === undefined) { + throw new Error(`session "${id}" contains an event that is not losslessly JSON-serializable`) + } + yield snapshot + } +} + +function deferred(): { + readonly promise: Promise + resolve(value: T): void + reject(reason: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +function decodedRead( + source: StoredSessionSource, + header: DecodedHeader, + requestedFromSeq: number, +): { + readonly events: AsyncIterable + readonly completed: Promise> +} { + const completion = deferred>() + const migrating = header.steps.length > 0 + const compatibility = header.unversionedCompatibility + let physical: StoredEventRead | undefined + + const events = (async function* (): AsyncIterable { + try { + let physicalFromSeq = migrating ? 0 : requestedFromSeq + physical = source.readEvents({ fromSeq: physicalFromSeq }) + void physical.completed.catch(() => undefined) + let raw: AsyncIterable = physical.events + let physicalCompletion: StoredEventReadCompletion | undefined + + if (!migrating && requestedFromSeq > 0 && compatibility !== undefined) { + const suffix: unknown[] = [] + for await (const value of raw) suffix.push(value) + physicalCompletion = await physical.completed + if (suffix.some(value => compatibility.requiresPrefix(value))) { + physicalFromSeq = 0 + physical = source.readEvents({ fromSeq: 0 }) + void physical.completed.catch(() => undefined) + raw = physical.events + physicalCompletion = undefined + } else { + raw = (async function* () { + for (const value of suffix) yield await Promise.resolve(value) + })() + } + } + + const storedEvents = snapshotStoredEvents(raw, header.meta.id) + const canonicalEvents = compatibility === undefined + ? storedEvents + : compatibility.canonicalizeEvents(storedEvents, header.meta.id) + const transformed = transformEvents( + canonicalEvents, + header.steps, + header.meta.id, + ) + const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq) + for await (const event of current) { + if (event.seq >= requestedFromSeq) yield event + } + completion.resolve(physicalCompletion ?? await physical.completed) + } catch (error: unknown) { + completion.reject(error) + throw error + } + })() + + return { events, completed: completion.promise } +} + +/** + * Decode one backend source through the static adjacent-version + * steps and the current header/event validators. Format selection is complete + * before any consumer-specific recovery runs. + * @param source - backend-owned header, revision, and event reader factory. + * @param expectedId - session identity selected by the caller. + * @param fromSeq - first current-format event sequence to return. + * @returns one decoded current-format stream bound to the stored revision. + */ +export function decodeStoredSession( + source: StoredSessionSource, + expectedId: SessionId, + fromSeq = 0, +): DecodedSession { + if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { + throw new TypeError(`stored event fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`) + } + const header = decodeHeader(source, expectedId) + const read = decodedRead(source, header, fromSeq) + return { + meta: header.meta, + sourceVersion: header.sourceVersion, + revision: source.revision, + events: read.events, + completed: read.completed, + } +} diff --git a/packages/session/session-persistence/src/format-migrations/index.ts b/packages/session/session-persistence/src/format-migrations/index.ts new file mode 100644 index 0000000000..ace5670d45 --- /dev/null +++ b/packages/session/session-persistence/src/format-migrations/index.ts @@ -0,0 +1,6 @@ +/** Static adjacent-version Session format steps shipped by this build. */ + +import type { SessionFormatStep } from '../format-decoder.ts' + +/** Ordered durable format steps; format v0 is current, so the chain is empty. */ +export const SESSION_FORMAT_STEPS: readonly SessionFormatStep[] = Object.freeze([]) diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts new file mode 100644 index 0000000000..760e8db8ce --- /dev/null +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -0,0 +1,293 @@ +/** + * Same-version normalization for durable format-v0 Session records. + * @module @deepseek-ai/dsh-session-persistence/format-v0-compat + */ + +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' + +/** One format-specific normalizer selected before adjacent-version migrations. */ +export interface UnversionedFormatCompatibility { + /** Header version whose historical records require this normalizer. */ + readonly version: number + /** + * Whether converting one suffix record requires facts from earlier events. + * @param value - parsed event JSON from a suffix read. + * @returns whether the decoder must reopen the complete event stream. + */ + requiresPrefix(value: unknown): boolean + /** + * Convert recognized historical records into the canonical representation + * carrying the same version number. + * @param events - parsed event JSON in durable sequence order. + * @param sessionId - identity read from the stored header. + * @returns a lazy stream in the canonical representation for {@link version}. + */ + canonicalizeEvents(events: AsyncIterable, sessionId: SessionId): AsyncIterable +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +function hasOnlyKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = [...required, ...optional] + return Object.keys(record).every(key => allowed.includes(key)) + && required.every(key => Object.hasOwn(record, key)) +} + +type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] + +function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { + return `legacy-message:${id}:${seq}` as PersistedMessageId +} + +function replacementStart(event: SessionEvent): number | undefined { + const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + return op?.['op'] === 'replace' && typeof op['start'] === 'number' + ? op['start'] + : undefined +} + +function requiresV0Prefix(value: unknown): boolean { + const event = asRecord(value) + if (event === undefined) return false + const data = asRecord(event['data']) + if (event['type'] === 'steering/message') return true + if (data === undefined) return false + switch (event['type']) { + case 'user/message': + return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content') + case 'assistant/message': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content') + case 'tool/result': + return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId') + default: + return false + } +} + +function readV0Event(value: unknown, id: SessionId): SessionEvent { + const event = asRecord(value) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} + +function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { + const legacyType: string = 'steering/message' + if (event.type !== legacyType) return event + const data = asRecord(event.data) + if (data === undefined) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const wrapped = asRecord(data['message']) + if (wrapped !== undefined && Number.isSafeInteger(data['turn']) + && hasOnlyKeys(data, ['turn', 'message'])) { + return { ...event, type: 'user/message', data: wrapped } as SessionEvent + } + if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) { + throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) + } + const { turn: _turn, ...message } = data + return { + ...event, + type: 'user/message', + data: { ...message, id: legacyMessageId(id, event.seq), role: 'user' }, + } as SessionEvent +} + +function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/start') return event + const data = asRecord(event.data) + if (data === undefined || !Object.hasOwn(data, 'trigger')) return event + const trigger = asRecord(data['trigger']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'trigger']) + || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`) + } + return { ...event, data: { turn: data['turn'] } } as SessionEvent +} + +function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { + if (event.type !== 'turn/end') return event + const data = asRecord(event.data) + if (data === undefined) return event + const malformed = (): never => { + throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) + } + const reason = asRecord(data['reason']) + if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 + || !hasOnlyKeys(data, ['turn', 'reason']) + || reason === undefined || typeof reason['kind'] !== 'string') return malformed() + + let currentReason: Record | undefined + switch (reason['kind']) { + case 'completed': + case 'blocked': + case 'max-tokens': + case 'interrupted': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + return event + case 'aborted': + if (Object.hasOwn(reason, 'reason')) return event + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'legacy' } } + break + case 'disposed': + if (!hasOnlyKeys(reason, ['kind'])) return malformed() + currentReason = { kind: 'aborted', reason: { kind: 'disposed' } } + break + case 'error': { + if (Object.hasOwn(reason, 'error')) return event + if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() + const failure = asRecord(reason['failure']) + if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) + && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) + && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' + && (failure['status'] === undefined || typeof failure['status'] === 'number') + && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number') + && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) { + currentReason = { kind: 'error', error: failure } + break + } + const messageKeys = reason['code'] === undefined + ? ['kind', 'step', 'message'] + : ['kind', 'step', 'message', 'code'] + if (!hasOnlyKeys(reason, messageKeys) + || typeof reason['message'] !== 'string' + || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed() + currentReason = { + kind: 'error', + error: { + message: reason['message'], + code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN', + }, + } + break + } + default: + return event + } + return { ...event, data: { ...data, reason: currentReason } } as SessionEvent +} + +function canonicalizeLegacyMessageEvent( + event: SessionEvent, + id: SessionId, + messageIds: ReadonlyMap, +): SessionEvent { + const data = asRecord(event.data) + if (data === undefined) return event + switch (event.type) { + case 'user/message': + if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') + || Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + return { ...event, data: { ...data, id: legacyMessageId(id, event.seq), role: 'user' } } as SessionEvent + case 'assistant/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event + const { content, provenance, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'assistant', + content, + source: { ...asRecord(provenance), kind: 'model' }, + }, + }, + } as SessionEvent + } + case 'tool/result': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') + || !Object.hasOwn(data, 'isError')) return event + const { callId, content, isError, ...eventData } = data + const inheritedId = replacementStart(event) + return { + ...event, + data: { + ...eventData, + message: { + id: inheritedId === undefined ? legacyMessageId(id, event.seq) : messageIds.get(inheritedId), + role: 'user', + content: [{ type: 'tool-result', toolCallId: callId, content, isError }], + source: { kind: 'tool', callId }, + }, + }, + } as SessionEvent + } + default: + return event + } +} + +function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { + const data = asRecord(event.data) + const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined +} + +async function* canonicalizeV0Events( + events: AsyncIterable, + id: SessionId, +): AsyncIterable { + const messageIds = new Map() + for await (const value of events) { + const event = readV0Event(value, id) + const turnStart = canonicalizeLegacyTurnStartEvent(event, id) + const turnEnd = canonicalizeLegacyTurnEndEvent(turnStart, id) + const steering = canonicalizeLegacySteeringEvent(turnEnd, id) + const canonical = canonicalizeLegacyMessageEvent(steering, id, messageIds) + const messageId = eventMessageId(canonical) + if (messageId !== undefined) messageIds.set(canonical.seq, messageId) + yield canonical + } +} + +/** + * Durable v0 includes first-party records whose structural changes were not + * accompanied by a format-version change. Their headers cannot select an + * adjacent-version migration, so this exact legacy recognition runs before + * any v0-to-v1 step and produces canonical v0 without changing the version. + * It remains necessary while v0 is current and whenever v0 is an upgrade + * source. Normalization alone is read-only; a selected versioned migration + * causes the canonicalized events to participate in atomic replacement. + */ +const V0_UNVERSIONED_FORMAT_COMPATIBILITY: UnversionedFormatCompatibility = Object.freeze({ + version: 0, + requiresPrefix: requiresV0Prefix, + canonicalizeEvents: canonicalizeV0Events, +}) + +/** + * Select same-version compatibility for one stored header version. + * @param version - format version read from the stored header. + * @returns the static normalizer for that version, if one is required. + */ +export function unversionedFormatCompatibility( + version: number, +): UnversionedFormatCompatibility | undefined { + return version === V0_UNVERSIONED_FORMAT_COMPATIBILITY.version + ? V0_UNVERSIONED_FORMAT_COMPATIBILITY + : undefined +} diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index d579dc46d9..fafb17e875 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -12,7 +12,7 @@ import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' -export { SessionPersistenceRevision } from './revision.ts' +export { SessionPersistenceRevision, SessionPersistenceRevisionConflictError } from './revision.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -46,17 +46,17 @@ export { DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, - SessionFormatUnsupportedError, SessionPersistenceCorruptionError, - sessionFormatVersionRefusal, } from './coordinator.ts' export type { PersistenceBackend, PersistenceCoordinatorOptions, - StoredPrefix, - StoredSuffix, } from './coordinator.ts' - +export { + decodeStoredSessionHeader, + SessionFormatUnsupportedError, + sessionFormatVersionRefusal, +} from './format-decoder.ts' declare module '@deepseek-ai/cordis' { interface Context { sessionPersistence: SessionPersistence @@ -241,3 +241,12 @@ export abstract class SessionPersistence extends Service { } export default SessionPersistence + +export type { + SessionFormatContext, + SessionFormatStep, + StoredEventRead, + StoredEventReadCompletion, + StoredEventReadOptions, + StoredSessionSource, +} from './format-decoder.ts' diff --git a/packages/session/session-persistence/src/revision.ts b/packages/session/session-persistence/src/revision.ts index cb037ffafc..36a79291b3 100644 --- a/packages/session/session-persistence/src/revision.ts +++ b/packages/session/session-persistence/src/revision.ts @@ -16,3 +16,12 @@ export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> export function SessionPersistenceRevision(value: string): SessionPersistenceRevision { return value as SessionPersistenceRevision } + +/** A repeatable source can no longer reproduce the revision it represents. */ +export class SessionPersistenceRevisionConflictError extends Error { + /** @param message - source identity and expected revision context. */ + constructor(message: string) { + super(message) + this.name = 'SessionPersistenceRevisionConflictError' + } +} diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts new file mode 100644 index 0000000000..e503095bf6 --- /dev/null +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -0,0 +1,889 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + SessionPersistenceRevision, + SessionPersistenceRevisionConflictError, +} from '../src/revision.ts' +import type { + SessionFormatContext, + SessionFormatStep, + StoredEventReadCompletion, + StoredSessionSource, +} from '../src/format-decoder.ts' +import { sessionFormatVersionRefusal } from '../src/format-decoder.ts' +import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts' + +const id = SessionId('format-migration') + +function eventLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +async function collectEvents(events: AsyncIterable): Promise { + const collected: SessionEvent[] = [] + for await (const event of events) collected.push(event) + return collected +} + +async function decodedFailure( + decoded: ReturnType, +): Promise { + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toBeInstanceOf(Error) + return streamFailure as Error +} + +function storedSource( + version: number, + events: readonly unknown[], +): { source: StoredSessionSource; reads: number[]; meta: Record } { + const reads: number[] = [] + const meta: Record = { version, id, createdAt: 1 } + return { + meta, + reads, + source: { + meta, + revision: SessionPersistenceRevision(`format-v${version}`), + readEvents({ fromSeq = 0 } = {}) { + reads.push(fromSeq) + return { + events: (async function* (): AsyncIterable { + for (const event of events) { + const seq = typeof event === 'object' && event !== null + ? (event as { seq?: unknown }).seq + : undefined + if (!Number.isSafeInteger(seq) || (seq as number) < 0 || (seq as number) >= fromSeq) { + yield structuredClone(event) + } + } + })(), + completed: Promise.resolve({}), + } + }, + }, + } +} + +function migration( + from: number, + calls: string[], +): SessionFormatStep { + return { + from, + to: from + 1, + migrateHeader(meta) { + calls.push(`header:${from}`) + return { ...(meta as Record), version: from + 1 } + }, + migrateEvents(events) { + return (async function* (): AsyncIterable { + let observedInput = false + for await (const value of events) { + if (!observedInput) { + calls.push(`events:${from}`) + observedInput = true + } + const event = value as SessionEvent + const data = event.data as Record + const migrationPath = Array.isArray(data['migrationPath']) + ? data['migrationPath'] as unknown[] + : [] + yield { + ...event, + data: { + ...data, + [`migratedFrom${from}`]: true, + migrationPath: [...migrationPath, from], + }, + } + } + })() + }, + } +} + +async function configuredDecoder( + currentVersion: number, + migrations: readonly SessionFormatStep[], + calls: string[] = [], +): Promise<{ + decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession'] + decodeStoredSessionHeader: typeof import('../src/format-decoder.ts')['decodeStoredSessionHeader'] + validateHeader: ReturnType +}> { + vi.resetModules() + const validateHeader = vi.fn((sessionId: SessionId, _seed: unknown, meta: unknown) => { + calls.push('validate-header') + const record = meta as Record + if (record['version'] !== currentVersion) { + throw new Error(`current header validator received v${String(record['version'])}`) + } + if (record['id'] !== sessionId) throw new Error('current header validator received the wrong id') + if (!Number.isSafeInteger(record['createdAt'])) { + throw new Error('current header validator received invalid createdAt') + } + return { header: Object.freeze(structuredClone(record)) } + }) + vi.doMock('@deepseek-ai/dsh-session', async () => { + const actual = await vi.importActual( + '@deepseek-ai/dsh-session', + ) + return { + ...actual, + SESSION_FORMAT_VERSION: currentVersion, + Session: { create: validateHeader }, + } + }) + vi.doMock('../src/format-migrations/index.ts', () => ({ + SESSION_FORMAT_STEPS: migrations, + })) + const decoder = await import('../src/format-decoder.ts') + return { + decodeStoredSession: decoder.decodeStoredSession, + decodeStoredSessionHeader: decoder.decodeStoredSessionHeader, + validateHeader, + } +} + +afterEach(() => { + vi.doUnmock('@deepseek-ai/dsh-session') + vi.doUnmock('../src/format-migrations/index.ts') + vi.resetModules() +}) + +describe('versioned Session format decoder', { concurrent: false }, () => { + it('describes both unsupported format directions', () => { + expect(sessionFormatVersionRefusal(id, 1)).toContain('newer harness') + expect(sessionFormatVersionRefusal(id, -1)).toContain('older than the supported') + }) + + it('runs a single migration lazily and reads the complete old log before slicing', async () => { + const calls: string[] = [] + const step = migration(0, calls) + const { decodeStoredSession, validateHeader } = await configuredDecoder(1, [step], calls) + const originalEvents = eventLog() + const originalSnapshot = structuredClone(originalEvents) + const stored = storedSource(0, originalEvents) + + const decoded = decodeStoredSession(stored.source, id, 1) + expect(decoded.sourceVersion).toBe(0) + expect(decoded.meta.version).toBe(1) + expect(calls).toEqual(['header:0', 'validate-header']) + expect(stored.reads).toEqual([]) + + const migrated = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([0]) + expect(calls).toEqual(['header:0', 'validate-header', 'events:0']) + expect(migrated).toEqual([ + { + ...originalEvents[1], + data: { ...originalEvents[1]?.data, migratedFrom0: true, migrationPath: [0] }, + }, + ]) + expect(originalEvents).toEqual(originalSnapshot) + expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 }) + expect(validateHeader).toHaveBeenCalledOnce() + }) + + it('lets an old-format suffix migration use facts from events before fromSeq', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + let previousSeq: number | undefined + for await (const value of events) { + const event = value as SessionEvent + yield previousSeq === undefined + ? event + : { ...event, data: { ...event.data, previousSeq } } + previousSeq = event.seq + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([0]) + expect(events).toEqual([{ + ...eventLog()[1], + data: { ...eventLog()[1]?.data, previousSeq: 0 }, + }]) + }) + + it('streams migrated events with backpressure instead of buffering the complete log', async () => { + const releaseTail = Promise.withResolvers() + const physicalCompletion = Promise.withResolvers>() + const reads: number[] = [] + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('streaming-source'), + readEvents({ fromSeq = 0 } = {}) { + reads.push(fromSeq) + return { + events: (async function* (): AsyncIterable { + try { + yield structuredClone(eventLog()[0]) + await releaseTail.promise + yield structuredClone(eventLog()[1]) + physicalCompletion.resolve({}) + } catch (error: unknown) { + physicalCompletion.reject(error) + throw error + } + })(), + completed: physicalCompletion.promise, + } + }, + } + const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])]) + const decoded = decodeStoredSession(source, id) + const iterator = decoded.events[Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { seq: 0 } }) + expect(reads).toEqual([0]) + let completed = false + void decoded.completed.then(() => { completed = true }) + await Promise.resolve() + expect(completed).toBe(false) + + releaseTail.resolve(undefined) + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { seq: 1 } }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('runs a complete multi-step chain before current header and event validation', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id) + expect(decoded.meta.version).toBe(2) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual([ + 'header:0', + 'header:1', + 'validate-header', + 'events:0', + 'events:1', + ]) + expect(events[0]?.data).toMatchObject({ migratedFrom0: true, migratedFrom1: true }) + expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) + }) + + it('plans by version even when registry entries are declared out of order', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(1, calls), migration(0, calls)], + calls, + ) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls.slice(0, 3)).toEqual(['header:0', 'header:1', 'validate-header']) + expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) + }) + + it('starts a multi-version registry at the source version', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(1, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual(['header:1', 'validate-header', 'events:1']) + expect(stored.reads).toEqual([0]) + expect(events[0]?.data).toMatchObject({ migrationPath: [1] }) + }) + + it('passes the stable stored identity to every header and event step', async () => { + const contexts: SessionFormatContext[] = [] + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader(meta, context) { + contexts.push(context) + return { ...(meta as Record), version: 1 } + }, + migrateEvents(events, context) { + contexts.push(context) + return events + }, + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + await collectEvents(decoded.events) + await decoded.completed + + expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }]) + }) + + it('migrates and validates a header without requiring an event source', async () => { + const calls: string[] = [] + const { decodeStoredSessionHeader } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + + const header = decodeStoredSessionHeader({ version: 0, id, createdAt: 1 }, id) + + expect(header.version).toBe(2) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + }) + + it('applies event migration before the current event vocabulary check', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as Record + yield { ...event, type: 'turn/start', data: { turn: 1 } } + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, [ + { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } }, + ]) + + const decoded = decodeStoredSession(stored.source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(events).toEqual([ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + ]) + }) + + it('uses suffix access directly for the current format', async () => { + const { decodeStoredSession } = await configuredDecoder(2, []) + const stored = storedSource(2, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([1]) + expect(events).toEqual(eventLog().slice(1)) + }) + + it('buffers a safe current-v0 suffix once without reopening the prefix', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + expect(await collectEvents(decoded.events)).toEqual(eventLog().slice(1)) + await expect(decoded.completed).resolves.toEqual({}) + + expect(stored.reads).toEqual([1]) + }) + + it('reopens the complete current-v0 log when a legacy suffix record needs its prefix', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const legacy = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { + type: 'steering/message', + seq: 1, + time: 2, + data: { turn: 1, content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }, + }, + ] + const stored = storedSource(0, legacy) + + const decoded = decodeStoredSession(stored.source, id, 1) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(stored.reads).toEqual([1, 0]) + expect(events).toMatchObject([{ type: 'user/message', seq: 1 }]) + }) + + it('observes a failed physical completion after reopening a required v0 prefix', async () => { + const failure = new SessionPersistenceRevisionConflictError('reopened prefix changed') + const fullCompletion = Promise.withResolvers>() + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('prefix-conflict'), + readEvents({ fromSeq = 0 } = {}) { + if (fromSeq > 0) { + return { + events: (async function* (): AsyncIterable { + yield { + type: 'steering/message', seq: 1, time: 2, + data: { turn: 1, content: [], source: { kind: 'user' } }, + } + })(), + completed: Promise.resolve({}), + } + } + return { + events: (async function* (): AsyncIterable { + fullCompletion.reject(failure) + throw failure + })(), + completed: fullCompletion.promise, + } + }, + } + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(source, id, 1) + + await expect(decodedFailure(decoded)).resolves.toBe(failure) + }) + + it('classifies every v0 prefix-independent suffix value without assuming a record', () => { + const compatibility = unversionedFormatCompatibility(0) + if (compatibility === undefined) throw new Error('v0 compatibility must be registered') + + expect(compatibility.requiresPrefix(null)).toBe(false) + expect(compatibility.requiresPrefix({ type: 'turn/end', data: null })).toBe(false) + expect(compatibility.requiresPrefix({ type: 'user/message', data: { id: 'current', content: [] } })).toBe(false) + expect(compatibility.requiresPrefix({ type: 'user/message', data: { content: [] } })).toBe(true) + expect(compatibility.requiresPrefix({ type: 'assistant/message', data: { content: [] } })).toBe(true) + expect(compatibility.requiresPrefix({ type: 'tool/result', data: { callId: 'call' } })).toBe(true) + }) + + it('preserves already-canonical v0 turn-end reasons', async () => { + const compatibility = unversionedFormatCompatibility(0) + if (compatibility === undefined) throw new Error('v0 compatibility must be registered') + const events = [ + { + type: 'turn/end', seq: 0, time: 1, + data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'disposed' } } }, + }, + { + type: 'turn/end', seq: 1, time: 2, + data: { turn: 2, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } }, + }, + ] + const input = (async function* (): AsyncIterable { + yield* events + })() + const canonical: unknown[] = [] + + for await (const event of compatibility.canonicalizeEvents(input, id)) canonical.push(event) + + expect(canonical).toEqual(events) + }) + + it('does not run older registered steps for an already-current source', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), migration(1, calls)], + calls, + ) + const stored = storedSource(2, eventLog()) + + const decoded = decodeStoredSession(stored.source, id, 1) + await collectEvents(decoded.events) + await decoded.completed + + expect(calls).toEqual(['validate-header']) + expect(stored.reads).toEqual([1]) + }) + + it('opens a fresh revision-bound reader for each decode of the same source', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + const first = decodeStoredSession(stored.source, id) + expect(await collectEvents(first.events)).toEqual(eventLog()) + await first.completed + const second = decodeStoredSession(stored.source, id) + expect(await collectEvents(second.events)).toEqual(eventLog()) + await second.completed + + expect(first.revision).toBe(second.revision) + expect(stored.reads).toEqual([0, 0]) + }) + + it('propagates a physical revision conflict unchanged through events and completion', async () => { + const failure = new SessionPersistenceRevisionConflictError('source changed') + const physicalCompletion = Promise.withResolvers>() + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('conflicting-source'), + readEvents: () => ({ + events: (async function* (): AsyncIterable { + physicalCompletion.reject(failure) + throw failure + })(), + completed: physicalCompletion.promise, + }), + } + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(failure) + expect(completionFailure).toBe(failure) + }) + + it('rejects a missing path and a future source in the correct direction', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(2, []) + const old = storedSource(0, []) + const future = storedSource(3, []) + + expect(() => decodeStoredSession(old.source, id)) + .toThrow(/missing v0 -> v1/) + expect(() => decodeStoredSession(future.source, id)) + .toThrow(/newer harness/) + expect(old.reads).toEqual([]) + expect(future.reads).toEqual([]) + expect(validateHeader).not.toHaveBeenCalled() + }) + + it('preserves the raw location in unsupported-format diagnostics', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(1, []) + const location = { kind: 'jsonl', path: '/tmp/session.jsonl' } + const source: StoredSessionSource = { ...stored.source, location } + + let failure: unknown + try { + decodeStoredSession(source, id) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + name: 'SessionFormatUnsupportedError', + location, + }) + expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)') + }) + + it('rejects an invalid suffix before validating the header or opening events', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(0, []) + const stored = storedSource(0, eventLog()) + + for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => decodeStoredSession(stored.source, id, fromSeq)) + .toThrow(/fromSeq must be a non-negative safe integer/) + } + expect(validateHeader).not.toHaveBeenCalled() + expect(stored.reads).toEqual([]) + }) + + it('validates unknown durable header fields before path selection', async () => { + const { decodeStoredSession, validateHeader } = await configuredDecoder(0, []) + const cases: Array<{ meta: unknown; message: RegExp }> = [ + { meta: null, message: /header is not a lossless JSON record/ }, + { meta: { version: '0', id }, message: /invalid format version/ }, + { meta: { version: 0, id: 42 }, message: /has no string id/ }, + ] + let reads = 0 + + for (const entry of cases) { + const source: StoredSessionSource = { + meta: entry.meta, + revision: SessionPersistenceRevision('invalid-header'), + readEvents: () => { + reads += 1 + return { events: (async function* () {})(), completed: Promise.resolve({}) } + }, + } + expect(() => decodeStoredSession(source, id)).toThrow(entry.message) + } + expect(reads).toBe(0) + expect(validateHeader).not.toHaveBeenCalled() + }) + + it('rejects every malformed current event envelope through the stream and completion', async () => { + const { decodeStoredSession } = await configuredDecoder(1, []) + const cases: Array<{ value: unknown; message: RegExp }> = [ + { value: null, message: /non-record event/ }, + { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ }, + { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ }, + { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ }, + { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ }, + ] + + for (const entry of cases) { + const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id) + expect((await decodedFailure(decoded)).message).toMatch(entry.message) + } + }) + + it('rejects a stored event that cannot be represented as JSON', async () => { + const { decodeStoredSession } = await configuredDecoder(1, []) + const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id) + + expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/) + }) + + it('rejects every malformed v0 event before same-version canonicalization', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const cases: Array<{ value: unknown; message: RegExp }> = [ + { value: null, message: /non-record event/ }, + { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ }, + { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ }, + { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ }, + { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ }, + ] + + for (const entry of cases) { + const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id) + expect((await decodedFailure(decoded)).message).toMatch(entry.message) + } + }) + + it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(storedSource(0, [ + { type: 'turn/end', seq: 0, time: 1, data: null }, + ]).source, id) + + await expect(collectEvents(decoded.events)).resolves.toEqual([ + { type: 'turn/end', seq: 0, time: 1, data: null }, + ]) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('rejects a step that returns the wrong header version', async () => { + const bad: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 0 }), + migrateEvents: events => events, + } + const first = await configuredDecoder(1, [bad]) + + expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) + .toThrow(/returned header version 0/) + expect(first.validateHeader).not.toHaveBeenCalled() + + const calls: string[] = [] + const badSecond: SessionFormatStep = { + from: 1, + to: 2, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => events, + } + const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls) + const stored = storedSource(0, []) + expect(() => second.decodeStoredSession(stored.source, id)) + .toThrow(/v1 -> v2 returned header version 1/) + expect(calls).toEqual(['header:0']) + expect(second.validateHeader).not.toHaveBeenCalled() + expect(stored.reads).toEqual([]) + }) + + it('rejects a step that changes the session id or cwd storage identity', async () => { + const changedId: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1, id: 'other' }), + migrateEvents: events => events, + } + const first = await configuredDecoder(1, [changedId]) + expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) + .toThrow(/changed session storage identity/) + + const changedCwd: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), + migrateEvents: events => events, + } + const second = await configuredDecoder(1, [changedCwd]) + const stored = storedSource(0, []) + stored.meta['cwd'] = '/work' + expect(() => second.decodeStoredSession(stored.source, id)) + .toThrow(/changed session storage identity/) + }) + + it('wraps a header migration failure with the failing version step', async () => { + const cause = new Error('bad legacy header') + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: () => { throw cause }, + migrateEvents: events => events, + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + + let failure: unknown + try { + decodeStoredSession(storedSource(0, []).source, id) + } catch (error: unknown) { + failure = error + } + expect(failure).toMatchObject({ + message: `session "${id}" header migration v0 -> v1 failed`, + cause, + }) + }) + + it('mirrors an event migration failure through the stream and completion promise', async () => { + const cause = new Error('bad legacy event') + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: () => (async function* (): AsyncIterable { + throw cause + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect(streamFailure).toMatchObject({ + message: `session "${id}" event migration v0 -> v1 failed`, + cause, + }) + }) + + it('runs current event validation on the migrated output', async () => { + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader: meta => ({ ...(meta as Record), version: 1 }), + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as SessionEvent + yield { ...event, seq: event.seq + 1 } + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const completion = decoded.completed.catch((error: unknown) => error) + const consumption = collectEvents(decoded.events).catch((error: unknown) => error) + + const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) + expect(streamFailure).toBe(completionFailure) + expect((streamFailure as Error).message).toMatch(/expected 0, got 1/) + }) + + it('runs current header validation only after the final header step', async () => { + const calls: string[] = [] + const finalStep: SessionFormatStep = { + from: 1, + to: 2, + migrateHeader(meta) { + calls.push('header:1') + const { createdAt: _createdAt, ...rest } = meta as Record + return { ...rest, version: 2 } + }, + migrateEvents: events => events, + } + const { decodeStoredSession } = await configuredDecoder( + 2, + [migration(0, calls), finalStep], + calls, + ) + const stored = storedSource(0, eventLog()) + + expect(() => decodeStoredSession(stored.source, id)) + .toThrow(/current header validator received invalid createdAt/) + expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) + expect(stored.reads).toEqual([]) + }) + + it('detaches stored header and event objects before a mutating migration runs', async () => { + const originalEvents = eventLog() + const eventSnapshot = structuredClone(originalEvents) + const step: SessionFormatStep = { + from: 0, + to: 1, + migrateHeader(meta) { + const record = meta as Record + record['version'] = 1 + return record + }, + migrateEvents: events => (async function* (): AsyncIterable { + for await (const value of events) { + const event = value as SessionEvent + const data = event.data as Record + data['mutated'] = true + yield event + } + })(), + } + const { decodeStoredSession } = await configuredDecoder(1, [step]) + const stored = storedSource(0, originalEvents) + + const decoded = decodeStoredSession(stored.source, id) + const migrated = await collectEvents(decoded.events) + await decoded.completed + + expect(migrated.every(event => (event.data as Record)['mutated'] === true)).toBe(true) + expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 }) + expect(originalEvents).toEqual(eventSnapshot) + }) + + it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => { + const calls: string[] = [] + await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) + .rejects.toThrow(/duplicate Session format step/) + + await expect(configuredDecoder(1, [migration(-1, calls)])) + .rejects.toThrow(/adjacent non-negative version/) + + const nonAdjacent: SessionFormatStep = { + ...migration(0, calls), + to: 2, + } + await expect(configuredDecoder(2, [nonAdjacent])) + .rejects.toThrow(/adjacent non-negative version/) + + const fractional: SessionFormatStep = { + ...migration(0, calls), + from: 0.5, + to: 1.5, + } + await expect(configuredDecoder(2, [fractional])) + .rejects.toThrow(/adjacent non-negative version/) + + await expect(configuredDecoder(1, [migration(1, calls)])) + .rejects.toThrow(/targets a version newer than this build/) + + await expect(configuredDecoder(2, [migration(0, calls)])) + .rejects.toThrow(/incomplete path/) + }) +}) diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 50ad798e04..4b5d6aabaf 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -5,10 +5,13 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix, + SessionPersistenceRevisionConflictError, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredEventRead, + type StoredEventReadCompletion, type StoredSessionSource, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' +import * as formatDecoder from '../src/format-decoder.ts' /** The durable store shape: materialized sessions only (no lazy entries). */ type MemoryStore = Map @@ -18,6 +21,45 @@ function memoryRevision(entry: { meta: SessionHeader; events: SessionEvent[] }): return SessionPersistenceRevision(JSON.stringify(entry)) } +/** Build one lazy physical read whose completion follows iterator exhaustion. */ +function storedRead( + load: () => Promise<{ events: readonly unknown[]; tornMarker?: TornMarker }>, +): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const loaded = await load() + yield* loaded.events + completed.resolve(loaded.tornMarker === undefined ? {} : { tornMarker: loaded.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } +} + +/** Materialize an async replacement stream for the map-backed test stores. */ +async function collectReplacement(events: AsyncIterable): Promise { + const collected: SessionEvent[] = [] + for await (const event of events) collected.push(structuredClone(event)) + return collected +} + +async function replaceMemoryStored( + store: MemoryStore, + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, +): Promise { + const entry = store.get(m.id) + if (entry === undefined || memoryRevision(entry) !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError(`session "${m.id}" changed before replacement`) + } + if (entry.meta.cwd !== m.cwd) throw new Error(`replacement for session "${m.id}" changes its stored identity`) + store.set(m.id, { meta: structuredClone(m), events: await collectReplacement(events) }) +} + /** An obsolete event fixture that emulates an untyped pre-change producer. */ function legacyHeaderDelta(seq = 0): SessionEvent { return { @@ -82,7 +124,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend super(ctx) // Assign the store BEFORE constructing the coordinator: the coordinator's // constructor installs the write path and synchronously seeds existing live - // sessions through loadStored(), so store must exist first. + // sessions through openStored(), so store must exist first. this.store = config?.store ?? new Map() this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -121,13 +163,20 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. - async loadStored(id: SessionId): Promise | undefined> { + async openStored(id: SessionId): Promise | undefined> { const entry = this.store.get(id) if (!entry) return undefined + const revision = memoryRevision(entry) return { meta: structuredClone(entry.meta), - events: structuredClone(entry.events), - revision: memoryRevision(entry), + revision, + readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => { + const current = this.store.get(id) + if (current === undefined || memoryRevision(current) !== revision) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`) + } + return { events: structuredClone(current.events.filter(event => event.seq >= fromSeq)) } + }), } } @@ -161,6 +210,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) } + async replaceStored( + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, + ): Promise { + await replaceMemoryStored(this.store, expectedRevision, m, events) + } + async list(signal?: AbortSignal): Promise { signal?.throwIfAborted() return [...this.store.values()].map(e => structuredClone(e.meta)) @@ -185,23 +242,36 @@ class ControlledBackend implements PersistenceBackend { repairAttempts = 0 beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise - /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ - seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise + /** Optional physical suffix hook used by readFrom-specific tests. */ + seekHook?: ( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ) => Promise<{ meta: SessionHeader; events: SessionEvent[] } | undefined> - loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') - return this.seekHook(id, fromSeq, signal) - } - - async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + async openStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { const attempt = ++this.loadAttempts await this.beforeLoadStored?.(attempt, signal) const entry = this.store.get(id) if (entry === undefined) return undefined + const revision = memoryRevision(entry) return { meta: structuredClone(entry.meta), - events: structuredClone(entry.events), - revision: memoryRevision(entry), + revision, + readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => { + signal?.throwIfAborted() + const loaded = this.seekHook === undefined + ? { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + : await this.seekHook(id, fromSeq, signal) + if (loaded === undefined) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" disappeared during read`) + } + const current = this.store.get(id) + if (current === undefined || memoryRevision(current) !== revision) { + throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`) + } + return { events: structuredClone(loaded.events) } + }), } } @@ -228,6 +298,14 @@ class ControlledBackend implements PersistenceBackend { if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[]) } + async replaceStored( + expectedRevision: SessionPersistenceRevision, + m: SessionHeader, + events: AsyncIterable, + ): Promise { + await replaceMemoryStored(this.store, expectedRevision, m, events) + } + async list(): Promise { return [...this.store.values()].map(entry => structuredClone(entry.meta)) } @@ -537,6 +615,11 @@ describe('PersistenceCoordinator session preparations', () => { }, { inject: ['sessions'] })) try { + const immediatelyLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) + const immediateGet = vi.spyOn(ctx.sessions, 'get').mockReturnValue(immediatelyLive) + await expect(coordinator.prepare(prepareId)).rejects.toThrow(/while it is live/) + immediateGet.mockRestore() + const prepareLive = Session.create(prepareId, oneTurnLog(), meta(prepareId)) const prepareGet = vi.spyOn(ctx.sessions, 'get') .mockReturnValueOnce(undefined) @@ -1281,7 +1364,7 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) - it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => { + it('readFrom via the source reader: serves the suffix, reports absence, and relays reader failures by abort state', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const backend = new ControlledBackend() @@ -1302,7 +1385,7 @@ describe('PersistenceCoordinator observation cancellation', () => { } const suffix = await coordinator.readFrom(id, 3) expect(suffix.events).toEqual(log.slice(3)) - // The hook's `undefined` is the backend contract's not-found result. + // Absence is established while opening the source, before an event read. await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found') // A hook failure with no cancellation in play propagates as-is. @@ -1310,6 +1393,20 @@ describe('PersistenceCoordinator observation cancellation', () => { backend.seekHook = () => Promise.reject(hookFailure) await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure) + // A revision conflict is retryable because it names no stable source. + let conflictAttempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + conflictAttempts += 1 + if (conflictAttempts === 1) { + throw new SessionPersistenceRevisionConflictError('source changed during readFrom') + } + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + await expect(coordinator.readFrom(id, 2)).resolves.toMatchObject({ events: log.slice(2) }) + expect(conflictAttempts).toBe(2) + // A hook failure after cancellation surfaces the caller's abort reason, // not the backend's internal teardown error. The abort fires only once // the hook is provably entered, so the failure exercises the catch (not @@ -1439,14 +1536,13 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) await ctx.sessions.flush(first) - // Occupy the per-id serialize chain with a gated physical read: + // Occupy the per-id serialize chain with a gated source open: // inspect() correctly borrows the still-live Session without entering // the backend chain, while both retirements must queue behind readFrom(). const readEntered = Promise.withResolvers() - backend.seekHook = async () => { + backend.beforeLoadStored = async () => { readEntered.resolve(undefined) await readGate.promise - return undefined } const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error) await readEntered.promise @@ -1470,7 +1566,7 @@ describe('PersistenceCoordinator retirement', () => { // delete the successor's entry (exact-entry guard); the successor's own // forget() then clears the map. readGate.resolve(true) - expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed + expect(await parked).toBeInstanceOf(Error) // the parked read (not found) is observed await firstRetirement await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) }) } finally { @@ -1879,6 +1975,235 @@ describe('SessionPersistence service registration', () => { await Promise.allSettled([fiber.dispose()]) }) + it('rejects obsolete event variants passed directly to the persistence writer', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const id = SessionId('legacy-direct-append') + await coordinator.create(meta(id)) + + try { + await expect(coordinator.append(id, [legacyHeaderDelta()])) + .rejects.toThrow(/unsupported legacy request\/header-delta event/) + await expect(coordinator.append(id, [legacyModeSet()])) + .rejects.toThrow(/unsupported legacy mode\/set event/) + await expect(coordinator.append(id, [legacyFallbackHeader()])) + .rejects.toThrow(/unsupported legacy request\/header reason "fallback"/) + expect(backend.store.has(id)).toBe(false) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries cold preparation when its physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepare-source-conflict') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('prepare source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(coordinator.inspect(id)).resolves.toMatchObject({ events: oneTurnLog() }) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries live-prefix adoption when the physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('hmr-source-conflict') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('live source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('retries ownerless seed verification when the physical source revision changes', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seed-source-conflict') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.load(id) + let attempts = 0 + backend.seekHook = async (hookId, fromSeq) => { + attempts += 1 + if (attempts === 1) throw new SessionPersistenceRevisionConflictError('seed source changed') + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) } + } + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + + await expect(ctx.sessions.flush(session)).resolves.toBe(true) + expect(attempts).toBe(2) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('streams an old-format prepared source into replacement and propagates non-conflict failures', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('prepared-format-replacement') + const m = meta(id) + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const source = { + inspection: Object.freeze({ meta: m, events: Object.freeze(oneTurnLog()) }), + session: Session.create(id, oneTurnLog(), m), + revision: memoryRevision(backend.store.get(id)!), + sourceVersion: -1, + sessionLength: oneTurnLog().length, + tornMarker: undefined, + closers: [], + } + const internals = coordinator as unknown as { + commitPrepared(value: typeof source): Promise + } + const replace = vi.spyOn(backend, 'replaceStored') + + try { + await expect(internals.commitPrepared(source)).resolves.toBeUndefined() + expect(replace).toHaveBeenCalledOnce() + expect(backend.store.get(id)?.events).toEqual(oneTurnLog()) + + const failure = new Error('replacement backend failed') + replace.mockRejectedValueOnce(failure) + source.revision = memoryRevision(backend.store.get(id)!) + await expect(internals.commitPrepared(source)).rejects.toBe(failure) + + replace.mockRejectedValueOnce(new SessionPersistenceRevisionConflictError('replacement raced')) + await expect(internals.commitPrepared(source)).resolves.toBeUndefined() + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('routes live adoption of a decoded old format through the same replacement primitive', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('live-format-replacement') + const m = meta(id, '/work') + const log = oneTurnLog() + backend.store.set(id, { meta: m, events: log }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const revision = memoryRevision(backend.store.get(id)!) + const stored: StoredSessionSource = { + meta: m, + revision, + readEvents: () => storedRead(async () => ({ events: log })), + } + const decoded = { + meta: m, + sourceVersion: -1, + revision, + events: (async function* (): AsyncIterable { yield* log })(), + completed: Promise.resolve({}), + } + const decode = vi.spyOn(formatDecoder, 'decodeStoredSession').mockReturnValue(decoded) + const replace = vi.spyOn(backend, 'replaceStored') + const internals = coordinator as unknown as { + adoptLivePrefix( + session: Session, + seed: readonly SessionEvent[], + source: StoredSessionSource, + ): Promise + } + + try { + const session = Session.create(id, log, m) + await expect(internals.adoptLivePrefix(session, log, stored)).resolves.toBe(false) + expect(replace).toHaveBeenCalledOnce() + expect(backend.store.get(id)?.events).toEqual(log) + } finally { + decode.mockRestore() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('propagates a non-conflict failure during ownerless seed verification', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seed-source-failure') + const m = meta(id, '/work') + backend.store.set(id, { meta: m, events: oneTurnLog() }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + await coordinator.load(id) + const failure = new Error('seed reader failed') + backend.seekHook = () => Promise.reject(failure) + const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } }) + + await expect(ctx.sessions.flush(session)).rejects.toBe(failure) + } finally { + await Promise.allSettled([fiber.dispose()]) + await ctx.fiber.dispose() + } + }) + it('rejects a stored legacy fallback header during load', async () => { const id = SessionId('legacy-fallback-load') const m = meta(id, '/legacy') From d4ff836dc23a9ddc731445a578949a7534d01be6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 18:06:48 +0800 Subject: [PATCH 02/37] refactor(session-persistence): share stored read machinery --- .../session-persistence-jsonl/src/index.ts | 43 +++------ .../session-persistence-sqlite/src/index.ts | 91 ++++++------------- .../session-persistence-sqlite/src/schema.ts | 14 +-- .../session-persistence/src/format-decoder.ts | 41 +-------- .../session-persistence/src/format-json.ts | 36 ++++++++ .../src/format-v0-compat.ts | 49 ++++------ .../session/session-persistence/src/index.ts | 33 ++++++- 7 files changed, 129 insertions(+), 178 deletions(-) create mode 100644 packages/session/session-persistence/src/format-json.ts diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index e16f343c8f..912ebd2152 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -106,20 +106,6 @@ interface JsonlStoredHeader { readonly revision: PersistenceRevision } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - interface FileRevisionIdentity { readonly dev: bigint readonly ino: bigint @@ -254,28 +240,23 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi revision, location: { kind: 'jsonl', path }, readEvents: (options = {}): StoredEventRead => { - const completed = deferred<{ tornMarker?: JsonlTornMarker }>() - const events = (async function* (backend: JsonlSessionPersistence): AsyncIterable { - try { - const prefix = await backend.readPrefix(path, id, signal) + const fromSeq = options.fromSeq ?? 0 + return this.createStoredEventRead( + async () => { + const prefix = await this.readPrefix(path, id, signal) if (prefix.revision !== revision) { throw new SessionPersistenceRevisionConflictError( `session "${id}" changed while reading revision ${revision}`, ) } - const fromSeq = options.fromSeq ?? 0 - for (const event of prefix.events) { - signal?.throwIfAborted() - const seq = (event as { seq?: unknown }).seq - if (typeof seq !== 'number' || seq >= fromSeq) yield event - } - completed.resolve(prefix.tornMarker === undefined ? {} : { tornMarker: prefix.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })(this) - return { events, completed: completed.promise } + return prefix + }, + (event) => { + const seq = (event as { seq?: unknown }).seq + return typeof seq !== 'number' || seq >= fromSeq + }, + signal, + ) }, } } diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 68becaec86..c6da30c1ec 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -59,20 +59,6 @@ interface SqliteStoredPrefix { readonly tornMarker?: number } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -238,29 +224,22 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers meta: rowToStoredMeta(row), revision, readEvents: (options = {}): StoredEventRead => { - const completed = deferred<{ tornMarker?: number }>() - const events = (async function* (backend: SqliteSessionPersistence): AsyncIterable { - try { + return this.createStoredEventRead( + async () => { const fromSeq = options.fromSeq ?? 0 const stored = fromSeq === 0 - ? await backend.readPrefix(id, signal) - : await backend.readSuffix(id, fromSeq, signal) + ? await this.readPrefix(id, signal) + : await this.readSuffix(id, fromSeq, signal) if (stored === undefined || stored.revision !== revision) { throw new SessionPersistenceRevisionConflictError( `session "${id}" changed while reading revision ${revision}`, ) } - for (const event of stored.events) { - signal?.throwIfAborted() - yield event - } - completed.resolve(stored.tornMarker === undefined ? {} : { tornMarker: stored.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })(this) - return { events, completed: completed.promise } + return stored + }, + () => true, + signal, + ) }, } } @@ -280,33 +259,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * region are dropped, never repaired (non-mutating read). */ private async readSuffix(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { - signal?.throwIfAborted() - await this.ready - signal?.throwIfAborted() - this.db.exec('BEGIN') - let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined - try { - const row = this.rowFor(id) - if (row !== undefined) { - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') - .all(id, fromSeq) as unknown as EventRow[] - snapshot = { row, eventRows } - } - this.db.exec('COMMIT') - } catch (error: unknown) { - this.db.exec('ROLLBACK') - throw error - } - signal?.throwIfAborted() - if (snapshot === undefined) return undefined - const { row, eventRows } = snapshot - const { preserved } = scanRows(eventRows, fromSeq) - return { - meta: rowToStoredMeta(row), - events: preserved, - revision: sqliteRevision(this.storeIdentity, row), - } + return this.readStoredEvents(id, fromSeq, false, signal) } /** @@ -315,6 +268,15 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers * (`scanRows` already returns it as `number | undefined`). */ private async readPrefix(id: SessionId, signal?: AbortSignal): Promise { + return this.readStoredEvents(id, 0, true, signal) + } + + private async readStoredEvents( + id: SessionId, + fromSeq: number, + includeTornMarker: boolean, + signal?: AbortSignal, + ): Promise { signal?.throwIfAborted() await this.ready signal?.throwIfAborted() @@ -323,27 +285,28 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers try { const row = this.rowFor(id) if (row !== undefined) { - const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') - .all(id) as unknown as EventRow[] + const statement = fromSeq === 0 + ? this.db.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') + : this.db.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + const eventRows = (fromSeq === 0 + ? statement.all(id) + : statement.all(id, fromSeq)) as unknown as EventRow[] snapshot = { row, eventRows } } this.db.exec('COMMIT') } catch (error: unknown) { - /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */ this.db.exec('ROLLBACK') throw error - /* v8 ignore stop */ } signal?.throwIfAborted() if (snapshot === undefined) return undefined const { row, eventRows } = snapshot - const { preserved, tornFrom } = scanRows(eventRows) + const { preserved, tornFrom } = scanRows(eventRows, fromSeq) return { meta: rowToStoredMeta(row), events: preserved, revision: sqliteRevision(this.storeIdentity, row), - ...tornFrom !== undefined ? { tornMarker: tornFrom } : {}, + ...includeTornMarker && tornFrom !== undefined ? { tornMarker: tornFrom } : {}, } } diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index e436486c09..634951e19c 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' -import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' /** * The on-disk schema version. Bumped only on a breaking change to the table @@ -180,17 +180,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) { throw new Error('stored session createdAt must be a non-negative safe integer') } - return { - version: row.version, - id: row.id as SessionId, - createdAt: row.created_at, - ...row.cwd !== null ? { cwd: row.cwd } : {}, - ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, - ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, - ...row.origin !== null ? { origin: row.origin } : {}, - ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {}, - ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {}, - } + return rowToStoredMeta(row) as SessionHeader } /** diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 2fbc45ccd3..df11403f64 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -17,6 +17,7 @@ import { unversionedFormatCompatibility, } from './format-v0-compat.ts' import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' +import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' import type { SessionPersistenceRevision } from './revision.ts' @@ -163,12 +164,6 @@ interface DecodedHeader { readonly unversionedCompatibility?: UnversionedFormatCompatibility } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - interface StoredHeaderSource { readonly meta: unknown readonly location?: SessionLocation @@ -190,7 +185,7 @@ function readSourceHeader( expectedId: SessionId, ): { meta: Record; version: number; id: SessionId } { const snapshot = snapshotJsonValue(source.meta) - const meta = asRecord(snapshot) + const meta = asStoredRecord(snapshot) if (meta === undefined) throw new Error('stored session header is not a lossless JSON record') if (!Number.isSafeInteger(meta['version'])) { throw new Error(`stored session header has invalid format version ${String(meta['version'])}`) @@ -243,7 +238,7 @@ function decodeHeader( { cause: error }, ) } - const record = asRecord(meta) + const record = asStoredRecord(meta) const actual = record?.['version'] if (actual !== step.to) { throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) @@ -282,19 +277,7 @@ export function decodeStoredSessionHeader( function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent { const snapshot = snapshotJsonValue(value) - const event = asRecord(snapshot) - if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) - if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) - if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { - throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) - } - if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) - } - if (!Object.hasOwn(event, 'data')) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) - } - return event as unknown as SessionEvent + return readStoredEventEnvelope(snapshot, id) } function assertCurrentEventSupported( @@ -376,20 +359,6 @@ async function* snapshotStoredEvents( } } -function deferred(): { - readonly promise: Promise - resolve(value: T): void - reject(reason: unknown): void -} { - let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((accept, decline) => { - resolve = accept - reject = decline - }) - return { promise, resolve, reject } -} - function decodedRead( source: StoredSessionSource, header: DecodedHeader, @@ -398,7 +367,7 @@ function decodedRead( readonly events: AsyncIterable readonly completed: Promise> } { - const completion = deferred>() + const completion = Promise.withResolvers>() const migrating = header.steps.length > 0 const compatibility = header.unversionedCompatibility let physical: StoredEventRead | undefined diff --git a/packages/session/session-persistence/src/format-json.ts b/packages/session/session-persistence/src/format-json.ts new file mode 100644 index 0000000000..f957a7e153 --- /dev/null +++ b/packages/session/session-persistence/src/format-json.ts @@ -0,0 +1,36 @@ +/** Shared JSON validation for stored Session format records. */ + +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' + +/** + * Narrow an unknown JSON value to a non-array object. + * @param value - parsed JSON value. + * @returns the object, or `undefined` for every other JSON value. + */ +export function asStoredRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +/** + * Validate fields common to every stored Session event envelope. + * @param value - detached parsed event JSON. + * @param id - Session identity used in diagnostics. + * @returns the structurally valid event envelope. + */ +export function readStoredEventEnvelope(value: unknown, id: SessionId): SessionEvent { + const event = asStoredRecord(value) + if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) + if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) + if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { + throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) + } + if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) + } + if (!Object.hasOwn(event, 'data')) { + throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) + } + return event as unknown as SessionEvent +} diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts index 760e8db8ce..f556936834 100644 --- a/packages/session/session-persistence/src/format-v0-compat.ts +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -4,6 +4,7 @@ */ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' /** One format-specific normalizer selected before adjacent-version migrations. */ export interface UnversionedFormatCompatibility { @@ -25,12 +26,6 @@ export interface UnversionedFormatCompatibility { canonicalizeEvents(events: AsyncIterable, sessionId: SessionId): AsyncIterable } -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined -} - function hasOnlyKeys( record: Record, required: readonly string[], @@ -48,16 +43,16 @@ function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { } function replacementStart(event: SessionEvent): number | undefined { - const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + const op = asStoredRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) return op?.['op'] === 'replace' && typeof op['start'] === 'number' ? op['start'] : undefined } function requiresV0Prefix(value: unknown): boolean { - const event = asRecord(value) + const event = asStoredRecord(value) if (event === undefined) return false - const data = asRecord(event['data']) + const data = asStoredRecord(event['data']) if (event['type'] === 'steering/message') return true if (data === undefined) return false switch (event['type']) { @@ -73,29 +68,17 @@ function requiresV0Prefix(value: unknown): boolean { } function readV0Event(value: unknown, id: SessionId): SessionEvent { - const event = asRecord(value) - if (event === undefined) throw new Error(`session "${id}" contains a non-record event`) - if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`) - if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) { - throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`) - } - if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`) - } - if (!Object.hasOwn(event, 'data')) { - throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`) - } - return event as unknown as SessionEvent + return readStoredEventEnvelope(value, id) } function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { const legacyType: string = 'steering/message' if (event.type !== legacyType) return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) { throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`) } - const wrapped = asRecord(data['message']) + const wrapped = asStoredRecord(data['message']) if (wrapped !== undefined && Number.isSafeInteger(data['turn']) && hasOnlyKeys(data, ['turn', 'message'])) { return { ...event, type: 'user/message', data: wrapped } as SessionEvent @@ -113,9 +96,9 @@ function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): Se function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent { if (event.type !== 'turn/start') return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined || !Object.hasOwn(data, 'trigger')) return event - const trigger = asRecord(data['trigger']) + const trigger = asStoredRecord(data['trigger']) if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 || !hasOnlyKeys(data, ['turn', 'trigger']) || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) { @@ -126,12 +109,12 @@ function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): S function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent { if (event.type !== 'turn/end') return event - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) return event const malformed = (): never => { throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`) } - const reason = asRecord(data['reason']) + const reason = asStoredRecord(data['reason']) if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1 || !hasOnlyKeys(data, ['turn', 'reason']) || reason === undefined || typeof reason['kind'] !== 'string') return malformed() @@ -156,7 +139,7 @@ function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): Ses case 'error': { if (Object.hasOwn(reason, 'error')) return event if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed() - const failure = asRecord(reason['failure']) + const failure = asStoredRecord(reason['failure']) if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure']) && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId']) && typeof failure['message'] === 'string' && typeof failure['code'] === 'string' @@ -192,7 +175,7 @@ function canonicalizeLegacyMessageEvent( id: SessionId, messageIds: ReadonlyMap, ): SessionEvent { - const data = asRecord(event.data) + const data = asStoredRecord(event.data) if (data === undefined) return event switch (event.type) { case 'user/message': @@ -212,7 +195,7 @@ function canonicalizeLegacyMessageEvent( id: legacyMessageId(id, event.seq), role: 'assistant', content, - source: { ...asRecord(provenance), kind: 'model' }, + source: { ...asStoredRecord(provenance), kind: 'model' }, }, }, } as SessionEvent @@ -242,8 +225,8 @@ function canonicalizeLegacyMessageEvent( } function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { - const data = asRecord(event.data) - const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + const data = asStoredRecord(event.data) + const message = event.type === 'user/message' ? data : asStoredRecord(data?.['message']) return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index fafb17e875..297a768b75 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -6,9 +6,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import { SessionPreparation } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionPreparation, type SessionEvent, type SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' +import type { StoredEventRead, StoredEventReadCompletion } from './format-decoder.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' @@ -86,6 +86,35 @@ export abstract class SessionPersistence extends Service { super(ctx, 'sessionPersistence') } + /** + * Build the standard lazy event stream and EOF metadata around one backend read. + * @param load - revision-checked batch loader owned by the backend. + * @param include - whether one loaded event belongs in this physical read. + * @param signal - optional cancellation checked between yielded events. + * @returns an independently consumable event read. + */ + protected createStoredEventRead( + load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, + include: (event: unknown) => boolean, + signal?: AbortSignal, + ): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const batch = await load() + for (const event of batch.events) { + signal?.throwIfAborted() + if (include(event)) yield event + } + completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } + } + /** * Resolve this backend's independent local artifact for a session without * reading, creating, flushing, or otherwise materializing it. Backends such From f73f2d9b65f07c8c6dc0c6d55211ac3d69ed4075 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 18:15:17 +0800 Subject: [PATCH 03/37] docs(config): refresh persistence source link --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 79b4f3b631..ec368e2aa6 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: 4d7e2f559328e8894c05ad0b9c791871b3b1434a -config-catalog.zh.md: 36988f2061326632b798d64d60eaf33ba7a5a765 +config-catalog.md: eec463096685b311c34e21c6284bcba0e34beadc +config-catalog.zh.md: d8d2c756797e8cce10fc6c829269d0a0e97bb4a5 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4d7e2f5593..eec4630966 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1635,7 +1635,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 36988f2061..d8d2c75679 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1637,7 +1637,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:79`](../packages/session/session-persistence-sqlite/src/index.ts) From c4b3f48e64204dd7a027bc0d3f010975aa9f0b88 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:07:23 +0800 Subject: [PATCH 04/37] fix(session): address format migration review --- .../README.i18n.yaml | 4 ++-- .../session-persistence-jsonl/README.md | 4 ++-- .../session-persistence-jsonl/README.zh.md | 4 ++-- .../session-persistence/README.i18n.yaml | 4 ++-- .../session/session-persistence/README.md | 20 ++++++++++------ .../session/session-persistence/README.zh.md | 20 ++++++++++------ .../session-persistence/src/coordinator.ts | 17 ++----------- .../session-persistence/src/format-decoder.ts | 24 +++++++------------ .../session-persistence/src/format-json.ts | 20 ++++++++++++++++ .../tests/format-decoder.spec.ts | 17 +++++++++++++ 10 files changed, 81 insertions(+), 53 deletions(-) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 2184f491c1..8048b9d849 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84 -README.zh.md: 38b41cb9f7fa0f144923feced06152a185254c0a +README.md: 00f893c134133207a1e9a12397c996c7c6c0c76c +README.zh.md: fc9ee432d162edeaf6472b31ca98004da11b7c16 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 4cff3215cd..00f893c134 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -35,7 +35,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. There is no migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format steps can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. ## Durability and crash semantics @@ -69,7 +69,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr ## Known Limitations and Deferred Work -- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. +- **Only format versions with a complete registered upgrade path load** — the registry is empty while `SESSION_FORMAT_VERSION` remains v0. Changing compression still requires a separate/fresh root or selecting the legacy raw mode. - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion API). diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 38b41cb9f7..fc9ee432d1 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -35,7 +35,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d 默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 -一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。不提供迁移、混合根回退或双写。 +一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式步骤可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 ## 持久性与崩溃语义 @@ -69,7 +69,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope ## 已知限制与暂缓事项 -- **只加载已配置编码和当前 `SESSION_FORMAT_VERSION`(v0)**:更改压缩需要独立/全新根,或选择遗留原始 mode;预发布格式没有迁移。 +- **只加载存在完整注册升级路径的格式版本**:`SESSION_FORMAT_VERSION` 保持 v0 时 registry 为空。更改压缩仍需要独立/全新根,或选择遗留原始 mode。 - **平铺文件存储布局不加载**:加载前使用独立根,或将预发布产物移入项目/会话目录布局。 - **压缩文件不能直接按行读取**:使用后端加载;或在写入新根前选择 `compression: 'none'`,以便外部行 reader 使用。 - **不删除会话文件**:日志在 `root` 下累积,直到外部移除(seam 无删除接口)。 diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 74b808b05e..dd8d1daf18 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: 335f40e7439fc4a49fa49bb4541f101250468d1b -README.zh.md: 5a50f051a2c53a5f27b009df11ad46f5952a32ac +README.md: 1ba00fc9e1139e7bf63f22a4014632cb705c0e68 +README.zh.md: b5e5896da80b9b53559319b63a9bbd858a216ea0 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 335f40e743..1ba00fc9e1 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -16,9 +16,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after decoding a supported format path and committing any format replacement plus cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format step requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -35,9 +35,15 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller. The first pending event starts a fixed batching window; later events join without resetting its deadline. The configured `writeBatchMaxDelayMs` bounds this intentional wait, not event-loop, initialization, serialized-operation, or backend latency. Events admitted during a write form a new bounded batch. `session/flush` cancels the wait and is a shared quiescence barrier that drains events admitted while it runs. A background failure is logged once, retains the ordered batch, and pauses automatic retry; a new event starts a fresh window, while explicit flush or backend teardown retries immediately and surfaces a repeated failure. -Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption opens the same revision-bound source, applies the coordinator's cwd check, and never closes the active turn. -Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise. +## Format decoding and upgrades + +Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, streams each `migrateEvents()` transform, and validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. + +A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. + +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions. These compatibility transforms are not version steps. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. @@ -48,11 +54,11 @@ The `PersistenceBackend` hooks (the only contract between the coordi | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; `revision` identifies exactly the returned header and events; an opaque `tornMarker` is present iff a torn tail must be truncated. | -| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. | -| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | +| `openStored(id, signal?)` | Open an untrusted header plus repeatable event readers bound to one exact source revision. Each `readEvents({ fromSeq? })` reproduces that revision and exposes backend-owned torn-tail metadata only after EOF, or rejects with `SessionPersistenceRevisionConflictError` when the source changed. | +| `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `openStored` and returns `undefined` when the id is absent. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | +| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur inside the same backend exclusion or transaction as the commit; a mismatch rejects with `SessionPersistenceRevisionConflictError`. | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 5a50f051a2..b5e5896da8 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -16,9 +16,9 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | +| `load(id): Promise<{ meta; events }>` | 沿受支持的格式路径解码,并提交格式替换与冷恢复后,返回不可变、平衡的逻辑日志。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式步骤时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -35,9 +35,15 @@ 每个 `session/event` 将事件复制到会话 controller。第一个待处理事件会开启固定批处理窗口;后续事件会加入该批次,但不会重置截止时间。配置的 `writeBatchMaxDelayMs` 只限制这段有意等待,而不限制事件循环、初始化、串行化操作或后端延迟。写入期间接纳的事件会形成一个新的有界批次。`session/flush` 会取消等待,并作为共享的完全停稳屏障,排空屏障运行期间接纳的事件。后台写入失败只记录一次日志,保留顺序不变的批次,并暂停自动重试;新事件会开启新的固定窗口,而显式 flush 或后端拆卸会立即重试,并在失败再次发生时向调用方暴露失败。 -崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +崩溃修复只适用于冷状态。对于已有活动会话的 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;活动会话中开放的轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR(热模块替换)接管打开同一份绑定 revision 的 source,应用协调器 cwd 检查,并绝不关闭活动轮次。 -后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +## 格式解码与升级 + +每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,以流式方式执行各个 `migrateEvents()` 转换,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 + +以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 + +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体。这些兼容转换不是版本步骤。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 @@ -48,11 +54,11 @@ | 钩子 | 职责 | |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | -| `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于恢复/加载、非修改式 inspect、活动会话接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;`revision` 精确标识返回的 header 和事件;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | -| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `loadStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | -| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非修改式、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | +| `openStored(id, signal?)` | 打开不可信 header 和绑定同一精确 source revision 的可重复事件 reader。每次 `readEvents({ fromSeq? })` 都重现该 revision,并只在 EOF 后暴露 backend 自有 torn-tail metadata;source 已变化时以 `SessionPersistenceRevisionConflictError` 拒绝。 | +| `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `openStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 | +| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查和提交位于同一个 backend 排他区或事务内;不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 0fa2bf1df9..c28f239d84 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -20,6 +20,7 @@ import { decodeStoredSession, SessionFormatUnsupportedError, } from './format-decoder.ts' +import { assertNoRetiredSessionEvent } from './format-json.ts' import type { DecodedSession, StoredSessionSource, @@ -194,21 +195,7 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio /** Reject obsolete v0 event records before a live writer persists them. */ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void { - const legacyType: string = 'request/header-delta' - const legacy = events.find(event => event.type === legacyType) - if (legacy !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`) - } - const legacyModeType: string = 'mode/set' - const legacyMode = events.find(event => event.type === legacyModeType) - if (legacyMode !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`) - } - const fallback = events.find(event => event.type === 'request/header' - && (event.data as { reason?: string }).reason === 'fallback') - if (fallback !== undefined) { - throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`) - } + for (const event of events) assertNoRetiredSessionEvent(event, id) } /** Materialize one decoded event read and observe its physical EOF metadata. */ diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index df11403f64..be10bd17ad 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -17,10 +17,10 @@ import { unversionedFormatCompatibility, } from './format-v0-compat.ts' import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' -import { asStoredRecord, readStoredEventEnvelope } from './format-json.ts' +import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' -import type { SessionPersistenceRevision } from './revision.ts' +import { SessionPersistenceRevisionConflictError, type SessionPersistenceRevision } from './revision.ts' /** Stable facts available to one format step invocation. */ export interface SessionFormatContext { @@ -35,14 +35,16 @@ export interface SessionFormatStep { /** Output Session format version; must equal `from + 1`. */ readonly to: number /** - * Transform and validate the header fields understood by this step. + * Transform and validate the header fields understood by this step. The + * detached result must carry {@link to} and preserve the source id and cwd. * @param meta - detached input header for {@link from}. * @param context - stable session identity. * @returns detached header JSON carrying {@link to}. */ migrateHeader(meta: unknown, context: SessionFormatContext): unknown /** - * Lazily transform and validate events understood by this step. + * Lazily transform and validate events understood by this step. The output + * must be detached, losslessly JSON-serializable, and contiguous by seq. * @param events - detached input records in durable sequence order. * @param context - stable session identity. * @returns a lazy output stream in the next format. @@ -285,18 +287,7 @@ function assertCurrentEventSupported( meta: SessionHeader, event: SessionEvent, ): void { - const legacyType: string = 'request/header-delta' - if (event.type === legacyType) { - throw new Error(`session "${meta.id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) - } - const legacyModeType: string = 'mode/set' - if (event.type === legacyModeType) { - throw new Error(`session "${meta.id}" contains unsupported legacy mode/set event at seq ${event.seq}`) - } - if (event.type === 'request/header' - && (event.data as { reason?: string }).reason === 'fallback') { - throw new Error(`session "${meta.id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) - } + assertNoRetiredSessionEvent(event, meta.id) if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return throw unsupported( source, @@ -336,6 +327,7 @@ function transformEvents( try { yield* step.migrateEvents(input, context) } catch (error: unknown) { + if (error instanceof SessionPersistenceRevisionConflictError) throw error throw new Error( `session "${id}" event migration v${step.from} -> v${step.to} failed`, { cause: error }, diff --git a/packages/session/session-persistence/src/format-json.ts b/packages/session/session-persistence/src/format-json.ts index f957a7e153..f4e911a220 100644 --- a/packages/session/session-persistence/src/format-json.ts +++ b/packages/session/session-persistence/src/format-json.ts @@ -34,3 +34,23 @@ export function readStoredEventEnvelope(value: unknown, id: SessionId): SessionE } return event as unknown as SessionEvent } + +/** + * Reject event records retired before the current durable event vocabulary. + * @param event - current-envelope event presented for reading or writing. + * @param id - Session identity used in diagnostics. + */ +export function assertNoRetiredSessionEvent(event: SessionEvent, id: SessionId): void { + const retiredType: string = 'request/header-delta' + if (event.type === retiredType) { + throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`) + } + const retiredModeType: string = 'mode/set' + if (event.type === retiredModeType) { + throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${event.seq}`) + } + if (event.type === 'request/header' + && (event.data as { reason?: string }).reason === 'fallback') { + throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`) + } +} diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index e503095bf6..6435eb7821 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -560,6 +560,23 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(completionFailure).toBe(failure) }) + it('propagates an upstream revision conflict unchanged through a migration step', async () => { + const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])]) + const { SessionPersistenceRevisionConflictError: DecoderRevisionConflictError } = await import('../src/revision.ts') + const failure = new DecoderRevisionConflictError('migrating source changed') + const source: StoredSessionSource = { + meta: { version: 0, id, createdAt: 1 }, + revision: SessionPersistenceRevision('conflicting-migration-source'), + readEvents: () => ({ + events: (async function* (): AsyncIterable { + throw failure + })(), + completed: Promise.reject(failure), + }), + } + await expect(decodedFailure(decodeStoredSession(source, id))).resolves.toBe(failure) + }) + it('rejects a missing path and a future source in the correct direction', async () => { const { decodeStoredSession, validateHeader } = await configuredDecoder(2, []) const old = storedSource(0, []) From 44feadea05f5a571b928923a418e068bc46ba0c0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:15:25 +0800 Subject: [PATCH 05/37] fix(session): load legacy compact events --- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../src/format-v0-compat.ts | 23 +++++++- .../tests/format-decoder.spec.ts | 53 +++++++++++++++++++ 8 files changed, 83 insertions(+), 9 deletions(-) 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 2d9bfcd187..f5b6075335 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: eb9fcef3fa677ee3912c2cbd611601b6b6f83db1 -2026-08-10-session-log-version-mechanism.zh.md: aa257482869a2b18bd0d3fcb2bc883a8cf506dcf +2026-08-10-session-log-version-mechanism.md: 058f3a98b67e35d6c6e46f7bb8bc625d419af7f1 +2026-08-10-session-log-version-mechanism.zh.md: 8738f3bbabd34804401859d847ca69b69a6b5d2b 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 eb9fcef3fa..058f3a98b6 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 @@ -18,7 +18,7 @@ Session logs must be upgradable after release, and the runtime that ships first **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 step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. -**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. 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 steps. +**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. 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 steps. 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. 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 aa25748286..8738f3bbab 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 @@ -18,7 +18,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 -**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。 +**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。该 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 后再继续。 diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index dd8d1daf18..ac91bae831 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: 1ba00fc9e1139e7bf63f22a4014632cb705c0e68 -README.zh.md: b5e5896da80b9b53559319b63a9bbd858a216ea0 +README.md: 5c14654dba4bc02a79113cb03fda881b30b1e0fc +README.zh.md: 843dff51da44aebd1c9e475a91ab045f4815ca89 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 1ba00fc9e1..5c14654dba 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -43,7 +43,7 @@ Every logical read opens a repeatable `StoredSessionSource` containing an untrus A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. -The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions. These compatibility transforms are not version steps. +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not version steps. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index b5e5896da8..843dff51da 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -43,7 +43,7 @@ 以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 -v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体。这些兼容转换不是版本步骤。 +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是版本步骤。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-persistence/src/format-v0-compat.ts b/packages/session/session-persistence/src/format-v0-compat.ts index f556936834..9ad747818e 100644 --- a/packages/session/session-persistence/src/format-v0-compat.ts +++ b/packages/session/session-persistence/src/format-v0-compat.ts @@ -71,6 +71,26 @@ function readV0Event(value: unknown, id: SessionId): SessionEvent { return readStoredEventEnvelope(value, id) } +/** + * PR #2302 changed these durable v0 discriminants without a format-version bump. + * @see https://github.com/deepseek-harness/deepseek-harness/pull/2302 + */ +function canonicalizeLegacyCompactionEvent(event: SessionEvent): SessionEvent { + const type: string = event.type + switch (type) { + case 'compact/start': + return { ...event, type: 'compaction/start' } as SessionEvent + case 'compact/summary': + return { ...event, type: 'compaction/summary' } as SessionEvent + case 'compact/end': + return { ...event, type: 'compaction/end' } as SessionEvent + case 'compact/prune': + return { ...event, type: 'compaction/prune' } as SessionEvent + default: + return event + } +} + function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent { const legacyType: string = 'steering/message' if (event.type !== legacyType) return event @@ -237,7 +257,8 @@ async function* canonicalizeV0Events( const messageIds = new Map() for await (const value of events) { const event = readV0Event(value, id) - const turnStart = canonicalizeLegacyTurnStartEvent(event, id) + const compaction = canonicalizeLegacyCompactionEvent(event) + const turnStart = canonicalizeLegacyTurnStartEvent(compaction, id) const turnEnd = canonicalizeLegacyTurnEndEvent(turnStart, id) const steering = canonicalizeLegacySteeringEvent(turnEnd, id) const canonical = canonicalizeLegacyMessageEvent(steering, id, messageIds) diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 6435eb7821..5d4c0c9d0e 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -504,6 +504,59 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(canonical).toEqual(events) }) + it('canonicalizes every historical compact event name without changing its record', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const events = [ + { + type: 'compact/start', seq: 0, time: 1, + data: { compactionId: 'legacy', turn: 1 }, + surfaceOp: { op: 'retain' }, + }, + { + type: 'compact/summary', seq: 1, time: 2, + data: { summary: 'old summary', shadowedSeqs: [7, 8] }, + durableMetadata: { source: 'historical-v0' }, + }, + { + type: 'compaction/end', seq: 2, time: 3, + data: { compactionId: 'current', turn: 1 }, + }, + { + type: 'compact/end', seq: 3, time: 4, + data: { compactionId: 'legacy', turn: 1 }, + }, + { + type: 'compact/prune', seq: 4, time: 5, + data: { + shadowedRange: { start: 7, end: 8 }, + shadowedSeqs: [7, 8], + shadowedTokenCount: 456, + }, + }, + ] + const stored = storedSource(0, events) + + const decoded = decodeStoredSession(stored.source, id) + const canonical = await collectEvents(decoded.events) + await decoded.completed + + expect(canonical).toEqual(events.map(event => ({ + ...event, + type: event.type.replace(/^compact\//, 'compaction/'), + }))) + expect(stored.reads).toEqual([0]) + }) + + it('still rejects other unknown v0 event names after compaction normalization', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const decoded = decodeStoredSession(storedSource(0, [{ + type: 'compact/future', seq: 0, time: 1, data: {}, + }]).source, id) + + const failure = await decodedFailure(decoded) + expect(failure.message).toMatch(/event type "compact\/future".*not marked ignorable/) + }) + it('does not run older registered steps for an already-current source', async () => { const calls: string[] = [] const { decodeStoredSession } = await configuredDecoder( From e8f4315ceed1ade5112b7b1dfa0d054e09c26cd6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 20:31:31 +0800 Subject: [PATCH 06/37] fix(session): scope format registry completeness to per-session decode buildStepIndex rejected the whole decoder at initialization whenever any registered step could not reach the current version, so one retired old upgrader blocked every session, including later versions whose path to the current version is complete. planSteps already refuses a specific stored version when a needed step is missing; initialization now checks only step legality and duplicates. --- .../session-persistence/src/format-decoder.ts | 13 +++------- .../tests/format-decoder.spec.ts | 26 ++++++++++++++++--- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index be10bd17ad..3a136441d0 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -145,15 +145,10 @@ function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap v${version + 1}`, - ) - } - } - } + // A missing step is a per-session concern, decided by planSteps() at decode + // time: it refuses sessions at or below the gap, while later versions whose + // path to the current version is complete still upgrade. Initialization + // therefore checks only step legality and duplicates here. return byFrom } diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 5d4c0c9d0e..30004d4f76 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -927,7 +927,7 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(originalEvents).toEqual(eventSnapshot) }) - it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => { + it('rejects duplicate, invalid, and future-targeting static registries at initialization', async () => { const calls: string[] = [] await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) .rejects.toThrow(/duplicate Session format step/) @@ -952,8 +952,28 @@ describe('versioned Session format decoder', { concurrent: false }, () => { await expect(configuredDecoder(1, [migration(1, calls)])) .rejects.toThrow(/targets a version newer than this build/) + }) - await expect(configuredDecoder(2, [migration(0, calls)])) - .rejects.toThrow(/incomplete path/) + it('initializes with a gapped registry and refuses only sessions at or below the gap', async () => { + const calls: string[] = [] + const { decodeStoredSession } = await configuredDecoder( + 3, + [migration(0, calls), migration(2, calls)], + calls, + ) + const current = storedSource(3, []) + const pastGap = storedSource(2, eventLog()) + const atGap = storedSource(1, eventLog()) + const belowGap = storedSource(0, eventLog()) + + expect(decodeStoredSession(current.source, id).meta.version).toBe(3) + const decoded = decodeStoredSession(pastGap.source, id) + expect(decoded.sourceVersion).toBe(2) + expect(decoded.meta.version).toBe(3) + expect(() => decodeStoredSession(atGap.source, id)) + .toThrow(/missing v1 -> v2/) + expect(() => decodeStoredSession(belowGap.source, id)) + .toThrow(/missing v1 -> v2/) + expect(pastGap.reads).toEqual([]) }) }) From 270a06b38bf7a6e0528659893249104eb184a10e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 20:42:56 +0800 Subject: [PATCH 07/37] fix(session-persistence-jsonl): drop crash-unsafe cross-process log lock The product model has no cross-process writer exclusion (the coordinator serializes per-session operations in-process; the README documents one live writer per session), so the wx-created .lock sibling only guarded byte-level races while adding two failure modes: a crash leaves a stale lock that permanently wedges that log's appends/repairs/replacements, and a post-commit lock cleanup failure makes a committed append look failed, so the retained write-behind batch retries into duplicate seqs. Remove withLogLock and keep replaceStored's revision compare-and-swap at the commit boundary (recheck immediately before the atomic rename). --- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 116 ++++++------------ .../tests/jsonl.spec.ts | 26 ---- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 5 +- 7 files changed, 46 insertions(+), 109 deletions(-) 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 058f3a98b6..8515d04ebf 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 @@ -22,7 +22,7 @@ Session logs must be upgradable after release, and the runtime that ships first **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 inside the commit exclusion. JSONL writes and fsyncs a sibling temporary artifact, rechecks the revision while holding its cross-process lock, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX. 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. +**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). 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 8738f3bbab..04e66b5da3 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 @@ -22,7 +22,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **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,在持有跨进程锁时复核 revision,原子替换路径(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。 +**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` 是现存例子)。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 912ebd2152..d4d8e5c19f 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' -import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate, writeFile } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' @@ -44,9 +44,6 @@ const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' * remains an indivisible synchronous decode. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 -const LOG_LOCK_RETRY_INITIAL_MS = 20 -const LOG_LOCK_RETRY_MAX_MS = 200 -const LOG_LOCK_TIMEOUT_MS = 2_000 const REPLACEMENT_BATCH_SIZE = 128 /** Assert that the independently decodable first frame contains only the header record. */ @@ -130,10 +127,6 @@ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } -function isEEXIST(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' -} - /** * The JSONL persistence backend. Load as a plugin; it registers as * `ctx.sessionPersistence` and (via the coordinator) installs the write-path @@ -484,8 +477,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ensureRootEncoding() if (isMaterialized) { - const path = logPath(this.root, meta.cwd, meta.id, this.compression) - await this.withLogLock(path, () => this.appendLines(meta, events)) + await this.appendLines(meta, events) } else { await this.materialize(meta, events) } @@ -501,12 +493,9 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi tornMarker: JsonlTornMarker | undefined, closers: readonly SessionEvent[], ): Promise { - const path = logPath(this.root, meta.cwd, meta.id, this.compression) - await this.withLogLock(path, async () => { - if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) - const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] - if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) - }) + if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo) + const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers] + if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents) } /** Replace one exact source revision through a synced sibling and atomic namespace update. */ @@ -522,48 +511,46 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi `session "${meta.id}" no longer has revision ${expectedRevision}`, ) } - await this.withLogLock(path, async () => { - let current: JsonlStoredHeader - try { - current = await this.readStoredHeader(path, meta.id) - } catch (error: unknown) { - if (isENOENT(error)) { - throw new SessionPersistenceRevisionConflictError( - `session "${meta.id}" no longer has revision ${expectedRevision}`, - ) - } - throw error + let current: JsonlStoredHeader + try { + current = await this.readStoredHeader(path, meta.id) + } catch (error: unknown) { + if (isENOENT(error)) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" no longer has revision ${expectedRevision}`, + ) } - if (current.revision !== expectedRevision) { + throw error + } + if (current.revision !== expectedRevision) { + throw new SessionPersistenceRevisionConflictError( + `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, + ) + } + const currentIdentity = this.storedIdentity(current.meta, path) + if (meta.cwd !== currentIdentity.cwd) { + throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + } + + const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` + try { + await this.writeReplacement(tmp, meta, events) + const beforeCommit = fileRevision(await stat(path, { bigint: true })) + if (beforeCommit !== expectedRevision) { throw new SessionPersistenceRevisionConflictError( `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, ) } - const currentIdentity = this.storedIdentity(current.meta, path) - if (meta.cwd !== currentIdentity.cwd) { - throw new Error(`replacement for session "${meta.id}" changes its stored identity`) + /* v8 ignore next -- Windows uses its write-through replacement primitive. */ + if (process.platform === 'win32') { + await replaceFileWin32(tmp, path) + } else { + await rename(tmp, path) + await this.syncDirPosix(dirname(path)) } - - const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp` - try { - await this.writeReplacement(tmp, meta, events) - const beforeCommit = fileRevision(await stat(path, { bigint: true })) - if (beforeCommit !== expectedRevision) { - throw new SessionPersistenceRevisionConflictError( - `session "${meta.id}" changed before replacement of revision ${expectedRevision}`, - ) - } - /* v8 ignore next -- Windows uses its write-through replacement primitive. */ - if (process.platform === 'win32') { - await replaceFileWin32(tmp, path) - } else { - await rename(tmp, path) - await this.syncDirPosix(dirname(path)) - } - } finally { - await rm(tmp, { force: true }) - } - }) + } finally { + await rm(tmp, { force: true }) + } } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ @@ -768,31 +755,6 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } - /** Serialize cooperating cross-process mutations of one materialized log. */ - private async withLogLock(path: string, operation: () => Promise): Promise { - const lockPath = `${path}.lock` - const deadline = Date.now() + LOG_LOCK_TIMEOUT_MS - let delay = LOG_LOCK_RETRY_INITIAL_MS - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' }) - break - } catch (error: unknown) { - if (!isEEXIST(error)) throw error - } - if (Date.now() >= deadline) { - throw new Error(`session log writer lock timed out at "${lockPath}"`) - } - await new Promise(resolve => setTimeout(resolve, delay)) - delay = Math.min(delay * 2, LOG_LOCK_RETRY_MAX_MS) - } - try { - return await operation() - } finally { - await rm(lockPath, { force: true }) - } - } - /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const header = JSON.stringify(toHeaderLine(meta)) + '\n' diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index ce6e99f30c..ff88640e34 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -672,32 +672,6 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(await collectStoredRead(replaced.readEvents())).toHaveLength(128) }) - it('retries a held writer lock and rejects lock acquisition failures and timeout', async () => { - const persistence = ctx.sessionPersistence as JsonlSessionPersistence - const internals = persistence as unknown as { - withLogLock(path: string, operation: () => Promise): Promise - } - const path = join(root, 'lock-target') - const lockPath = `${path}.lock` - await writeFile(lockPath, 'held\n') - const released = new Promise((resolveRelease) => { - setTimeout(() => { void rm(lockPath).then(() => { resolveRelease() }) }, 5) - }) - await expect(internals.withLogLock(path, async () => 'committed')).resolves.toBe('committed') - await released - - await expect(internals.withLogLock(`${root}\0invalid`, async () => undefined)) - .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) - - await writeFile(lockPath, 'held\n') - const now = vi.spyOn(Date, 'now') - .mockReturnValueOnce(1_000) - .mockReturnValueOnce(3_001) - await expect(internals.withLogLock(path, async () => undefined)) - .rejects.toThrow(/writer lock timed out/) - now.mockRestore() - }) - it('rejects malformed version-independent storage identity fields', async () => { const persistence = ctx.sessionPersistence as JsonlSessionPersistence const path = rawLogPath(root, '/work', SessionId('identity-fields')) diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 5c14654dba..394fe18799 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -58,7 +58,7 @@ The `PersistenceBackend` hooks (the only contract between the coordi | `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `openStored` and returns `undefined` when the id is absent. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | -| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur inside the same backend exclusion or transaction as the commit; a mismatch rejects with `SessionPersistenceRevisionConflictError`. | +| `replaceStored(expectedRevision, meta, events)` | Atomically replace one exact revision with a complete current-format header and event stream. Revision and stored identity checks occur at the commit boundary — immediately before the atomic rename on JSONL, inside the replacing transaction on SQLite; the checks add no cross-process writer exclusion. A mismatch rejects with `SessionPersistenceRevisionConflictError`. | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 843dff51da..4ef93f41ac 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -58,7 +58,7 @@ v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/note | `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `openStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 | -| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查和提交位于同一个 backend 排他区或事务内;不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | +| `replaceStored(expectedRevision, meta, events)` | 用完整的当前格式 header 与事件流原子替换一个精确 revision。Revision 与存储身份检查发生在提交边界——JSONL 在原子替换前立即检查,SQLite 在替换事务内检查;该检查不提供跨进程写者排他。不匹配时以 `SessionPersistenceRevisionConflictError` 拒绝。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index c28f239d84..c126b0bed1 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -110,8 +110,9 @@ export interface PersistenceBackend { /** * Atomically replace one exact stored revision with a complete current log. - * The backend checks revision and storage identity in the same exclusive file - * operation or database transaction that commits the replacement. + * The backend checks revision and storage identity at the commit boundary: + * immediately before the atomic rename on JSONL, inside the replacing + * transaction on SQLite. The check adds no cross-process writer exclusion. * @param expectedRevision - exact source revision decoded by the caller. * @param meta - complete current-format header. * @param events - complete current-format event stream. From 50ad2aba19ff5973956c29d031bbe95f5a6dd1b1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 20 Aug 2026 14:21:47 +0800 Subject: [PATCH 08/37] fix(session-persistence): repair persistence CI gates - document the exported SqliteStore prefix/suffix loaders (verify-export-jsdoc) - share createStoredEventRead from session-persistence so the standalone SQLite store stops duplicating the service helper (duplication gate) - route replaceStored header upserts through writeRow (duplication gate) - move replacement/conflict test SQL into closed test resources so the SQLite SQL resource boundary test passes --- .../session-persistence-sqlite/src/store.ts | 58 +++++-------------- .../resources/sql/count-session-events.sql | 3 + .../sql/create-temp-replace-trigger.sql | 5 ++ .../resources/sql/delete-session-by-id.sql | 2 + .../sql/drop-temp-replace-trigger.sql | 1 + .../resources/sql/update-session-cwd.sql | 3 + .../resources/sql/update-session-revision.sql | 3 + .../tests/sqlite.spec.ts | 22 +++---- .../tests/test-sql.ts | 6 ++ .../session-persistence/src/format-decoder.ts | 30 ++++++++++ .../session/session-persistence/src/index.ts | 19 +----- 11 files changed, 79 insertions(+), 73 deletions(-) create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql create mode 100644 packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts index e1e3f8b651..02223ee780 100644 --- a/packages/session/session-persistence-sqlite/src/store.ts +++ b/packages/session/session-persistence-sqlite/src/store.ts @@ -15,6 +15,7 @@ import { type SessionHeader, } from '@deepseek-ai/dsh-session' import { + createStoredEventRead, decodeStoredSessionHeader, SessionPersistenceRevision, SessionPersistenceRevisionConflictError, @@ -22,7 +23,6 @@ import { type SessionPersistenceRevision as PersistenceRevision, type SessionPersistenceSnapshot, type StoredEventRead, - type StoredEventReadCompletion, type StoredEventReadOptions, type StoredSessionSource, } from '@deepseek-ai/dsh-session-persistence' @@ -71,36 +71,6 @@ interface SqliteStoredSuffix { readonly revision: PersistenceRevision } -/** - * Build the standard lazy event stream and EOF metadata around one backend - * read, mirroring the service's protected helper for standalone stores. - * @param load - revision-checked batch loader owned by the backend. - * @param include - whether one loaded event belongs in this physical read. - * @param signal - optional cancellation checked between yielded events. - * @returns an independently consumable event read. - */ -function createStoredEventRead( - load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, - include: (event: unknown) => boolean, - signal?: AbortSignal, -): StoredEventRead { - const completed = Promise.withResolvers>() - const events = (async function* (): AsyncIterable { - try { - const batch = await load() - for (const event of batch.events) { - signal?.throwIfAborted() - if (include(event)) yield event - } - completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })() - return { events, completed: completed.promise } -} - /** SQLite implementation of the coordinator's physical backend hooks. */ export class SqliteStore implements PersistenceBackend { readonly name = 'session-persistence-sqlite' @@ -180,6 +150,12 @@ export class SqliteStore implements PersistenceBackend { } } + /** + * Load one row's complete validated prefix at a single snapshot. + * @param id - persisted session id to resolve. + * @param signal - optional cancellation for backend read work. + * @returns the stored prefix, or `undefined` when the session has no stored row. + */ async loadStored(id: SessionId, signal?: AbortSignal): Promise { await this.observe(signal) const snapshot = this.readTransaction(() => { @@ -206,6 +182,13 @@ export class SqliteStore implements PersistenceBackend { return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row) } + /** + * Load one row's physical suffix at or past a sequence at a single snapshot. + * @param id - persisted session id to resolve. + * @param fromSeq - first physical event sequence to include. + * @param signal - optional cancellation for backend read work. + * @returns the stored suffix, or `undefined` when the session has no stored row. + */ async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { await this.observe(signal) const snapshot = this.readTransaction(() => { @@ -373,18 +356,7 @@ export class SqliteStore implements PersistenceBackend { this.db.prepare(sql('delete-events-from')).run(meta.id, 0) const insert = this.insertStatement() for (const record of records) this.insertRecord(insert, meta.id, bindRecord(record)) - this.db.prepare(sql('upsert-session')).run( - meta.id, - meta.version, - meta.createdAt, - meta.cwd ?? null, - meta.parentSession ?? null, - meta.seedLength ?? null, - meta.origin ?? null, - meta.delegationDepth ?? null, - meta.agentPreset ?? null, - randomUUID(), - ) + this.writeRow(meta) this.incrementRevision(meta.id) this.db.exec(sql('commit')) } catch (error: unknown) { diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql new file mode 100644 index 0000000000..da02575e16 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-session-events.sql @@ -0,0 +1,3 @@ +SELECT COUNT(*) AS n +FROM events +WHERE session_id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql new file mode 100644 index 0000000000..1fbf6ae0c7 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-temp-replace-trigger.sql @@ -0,0 +1,5 @@ +CREATE TEMP TRIGGER fail_format_replace +BEFORE UPDATE ON sessions +BEGIN + SELECT RAISE(ABORT, 'simulated format replacement failure'); +END diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql new file mode 100644 index 0000000000..afe9d6c0ec --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-by-id.sql @@ -0,0 +1,2 @@ +DELETE FROM sessions +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql new file mode 100644 index 0000000000..b41f647451 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/drop-temp-replace-trigger.sql @@ -0,0 +1 @@ +DROP TRIGGER fail_format_replace; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql new file mode 100644 index 0000000000..7586325b16 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-cwd.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET cwd = ? +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql new file mode 100644 index 0000000000..2cfbcb2b82 --- /dev/null +++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-session-revision.sql @@ -0,0 +1,3 @@ +UPDATE sessions +SET revision = revision + 1 +WHERE id = ?; diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 448bff85ee..55d6e26862 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -871,7 +871,7 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const removed = await store.openStored(m.id) if (removed === undefined) throw new Error('test session must remain materialized') const db = (store as unknown as { db: DatabaseSync }).db - db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + db.prepare(testSql('delete-session-by-id')).run(m.id) const removedRead = removed.readEvents({ fromSeq: 1 }) const removedCompletion = removedRead.completed.catch((error: unknown) => error) await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })()) @@ -896,7 +896,7 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () }) await expect(store.loadStoredFrom(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure') spy.mockRestore() - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -944,11 +944,11 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const db = (store as unknown as { db: DatabaseSync }).db const changesDuringStaging = (async function* (): AsyncIterable { yield* oneTurnLog() - db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id) + db.prepare(testSql('update-session-cwd')).run('/raced', m.id) })() await expect(store.replaceStored(source.revision, m, changesDuringStaging)) .rejects.toThrow(/changes its stored identity/) - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -963,12 +963,12 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const db = (store as unknown as { db: DatabaseSync }).db const changesDuringStaging = (async function* (): AsyncIterable { yield* oneTurnLog() - db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id) + db.prepare(testSql('update-session-revision')).run(m.id) })() await expect(store.replaceStored(source.revision, m, changesDuringStaging)) .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError) - expect((db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n) + expect((db.prepare(testSql('count-session-events')).get(m.id) as { n: number }).n) .toBe(oneTurnLog().length) await store.close() }) @@ -981,18 +981,12 @@ describe('SessionPersistenceSqlite stored-source and replacement primitives', () const source = await store.openStored(m.id) if (source === undefined) throw new Error('test session must be materialized') const db = (store as unknown as { db: DatabaseSync }).db - db.exec(` - CREATE TEMP TRIGGER fail_format_replace - BEFORE UPDATE ON sessions - BEGIN - SELECT RAISE(ABORT, 'simulated format replacement failure'); - END - `) + db.exec(testSql('create-temp-replace-trigger')) await expect( store.replaceStored(source.revision, m, replacementEvents([])), ).rejects.toThrow(/simulated format replacement failure/) - db.exec('DROP TRIGGER fail_format_replace') + db.exec(testSql('drop-temp-replace-trigger')) expect((await store.loadStored(m.id))?.events).toEqual(oneTurnLog()) await store.close() diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts index 77b53a404e..beb11d6f65 100644 --- a/packages/session/session-persistence-sqlite/tests/test-sql.ts +++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts @@ -8,10 +8,14 @@ export type TestSqlName = | 'count-ignorable-events' | 'count-packed-events' | 'count-physical-types' + | 'count-session-events' | 'create-loose-schema' + | 'create-temp-replace-trigger' | 'create-unrelated-table' | 'delete-persistence-state' + | 'delete-session-by-id' | 'delete-session-events' + | 'drop-temp-replace-trigger' | 'empty-store-id' | 'insert-corrupt-event' | 'measure-write-traffic' @@ -25,6 +29,8 @@ export type TestSqlName = | 'set-user-version-16' | 'set-user-version-17' | 'update-invalid-session-metadata' + | 'update-session-cwd' + | 'update-session-revision' /** Load one fixed test SQL resource. */ export function testSql(name: TestSqlName): string { diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 3a136441d0..b5e38c4e98 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -89,6 +89,36 @@ export interface StoredSessionSource { readEvents(options?: StoredEventReadOptions): StoredEventRead } +/** + * Build the standard lazy event stream and EOF metadata around one backend + * read, shared by every first-party backend. + * @param load - revision-checked batch loader owned by the backend. + * @param include - whether one loaded event belongs in this physical read. + * @param signal - optional cancellation checked between yielded events. + * @returns an independently consumable event read. + */ +export function createStoredEventRead( + load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, + include: (event: unknown) => boolean, + signal?: AbortSignal, +): StoredEventRead { + const completed = Promise.withResolvers>() + const events = (async function* (): AsyncIterable { + try { + const batch = await load() + for (const event of batch.events) { + signal?.throwIfAborted() + if (include(event)) yield event + } + completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) + } catch (error: unknown) { + completed.reject(error) + throw error + } + })() + return { events, completed: completed.promise } +} + /** One decoded current-format read bound to an exact stored revision. */ export interface DecodedSession { /** Validated current-format header. */ diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 297a768b75..1fd2c3a73d 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -8,7 +8,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { SessionPreparation, type SessionEvent, type SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' -import type { StoredEventRead, StoredEventReadCompletion } from './format-decoder.ts' +import { createStoredEventRead, type StoredEventRead } from './format-decoder.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. export type { SessionHeader } from '@deepseek-ai/dsh-session' @@ -53,6 +53,7 @@ export type { PersistenceCoordinatorOptions, } from './coordinator.ts' export { + createStoredEventRead, decodeStoredSessionHeader, SessionFormatUnsupportedError, sessionFormatVersionRefusal, @@ -98,21 +99,7 @@ export abstract class SessionPersistence extends Service { include: (event: unknown) => boolean, signal?: AbortSignal, ): StoredEventRead { - const completed = Promise.withResolvers>() - const events = (async function* (): AsyncIterable { - try { - const batch = await load() - for (const event of batch.events) { - signal?.throwIfAborted() - if (include(event)) yield event - } - completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) - } catch (error: unknown) { - completed.reject(error) - throw error - } - })() - return { events, completed: completed.promise } + return createStoredEventRead(load, include, signal) } /** From a693e0764b549c9f283ef847c77687e08190709b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 13:09:33 +0800 Subject: [PATCH 09/37] docs: revert spurious BRAND-GUIDELINES.md change from master merge --- BRAND-GUIDELINES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BRAND-GUIDELINES.md b/BRAND-GUIDELINES.md index 96ecc5d922..7220a570a0 100644 --- a/BRAND-GUIDELINES.md +++ b/BRAND-GUIDELINES.md @@ -18,4 +18,5 @@ To maintain the long\-term healthy development of the DeepSeek Harness ecosystem - When naming your project, please avoid using the full "DeepSeek Harness" trademark directly\. "DeepSeek Harness" is a registered trademark of DeepSeek\. Unauthorized use in project names can easily lead to user misunderstanding and confusion, thereby affecting the clarity of the entire ecosystem\. It may also involve trademark infringement\. - Additionally, please avoid using official brand materials in your promotions or presentations in a way that could cause misunderstanding, so as not to give users the false impression of official endorsement, cooperation, or authorization\. -We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\. \ No newline at end of file +We believe that a clear and orderly community environment will make every developer's efforts more visible and more readily recognized\. For the few cases that do not comply with the above specifications, we may contact the relevant parties to make appropriate adjustments in order to maintain the overall order of the ecosystem\. Thank you for your understanding and support—let us work together to build a more friendly and sustainable open\-source community\. + From 2da00047f3348ebdf0c2cd979c11c4c85063c938 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 15:50:30 +0800 Subject: [PATCH 10/37] refactor(session-persistence): make format migrations one-to-one --- ...10-session-log-version-mechanism.i18n.yaml | 4 +- ...026-08-10-session-log-version-mechanism.md | 8 +- ...-08-10-session-log-version-mechanism.zh.md | 8 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 8 +- .../session/session-persistence/README.zh.md | 8 +- .../session-persistence/src/format-decoder.ts | 167 ++++---- .../src/format-migrations/index.ts | 8 +- .../session/session-persistence/src/index.ts | 3 +- .../tests/format-decoder.spec.ts | 377 ++++++++++-------- 13 files changed, 344 insertions(+), 259 deletions(-) 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 1ce9bd62d0..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: 8515d04ebfbd9e23ff5b58ca8b4bb888f8ba5dc9 -2026-08-10-session-log-version-mechanism.zh.md: 04e66b5da3a14d1c3f919cb5cdb8ded0f76a2baf +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 8515d04ebf..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,11 +14,11 @@ 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: 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 `SessionFormatStep`s; a missing step 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. +**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 step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain. +**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 step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. 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 steps. 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. +**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. @@ -36,6 +36,6 @@ Format v0 carries direction-aware refusal with the raw-log path; the unknown-eve - **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 iterator transforms preserve retry semantics without imposing that allocation. +- **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 04e66b5da3..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,11 +14,11 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 -**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatStep` 组成完整链路,缺失任何一步都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 +**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatMigration` 类组成完整链路,缺失任何 migration 都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。 -**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。 +**格式迁移就是 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 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。该 decoder 将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称映射为规范的 `compaction/*` 事件,并保留每条记录的其余内容。 +**以后每次 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 后再继续。 @@ -36,6 +36,6 @@ Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 - **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 - **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 -- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加 iterator 转换保留重试语义,又不强制这笔分配。 +- **把 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/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index baa245bcd8..31542655a8 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: ef193806ce6234d1c25e2118ca2db7233c0b9b2e -persistence.zh.md: 5fa47497249888f478e471e2798b6dfcc724db84 +persistence.md: 7a159b37a561977f4af20dae1097eef8dc7df343 +persistence.zh.md: a8a306d025f73eb221dc456a605bd934510fd39f diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index ef193806ce..7a159b37a5 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -381,5 +381,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 5fa4749724..a8a306d025 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -381,5 +381,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index cb4e019406..8a7e0c383a 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: 394fe18799191f9a190a3e2f9ed68aa740bf6695 -README.zh.md: 4ef93f41ac8b88843e14d6e8ce14f147a79a508e +README.md: 4a3111f8e3d38add9204b121dd100c9fa5a78d7c +README.zh.md: 02010582fd04afe8dd45975d4fb080fd5288733e diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 394fe18799..4a3111f8e3 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -18,7 +18,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after decoding a supported format path and committing any format replacement plus cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format step requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Current-format reads request a suffix from the backend; a format migration requires the complete source and applies `fromSeq` only after migration. Sequential media may still scan framing before filtering, while seek-capable media can avoid reading earlier rows. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -39,11 +39,11 @@ Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative ## Format decoding and upgrades -Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, streams each `migrateEvents()` transform, and validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. +Every logical read opens a repeatable `StoredSessionSource` containing an untrusted header, an exact revision, and a `readEvents()` factory. The static decoder chooses a complete adjacent-version path, creates one migration instance per version, calls `header()` once, calls `event()` once per input record, and calls optional `finish()` after EOF. It then validates the final header and events as the current format. `inspect()` and `readFrom()` do not write. Cold continuation and live adoption replace a converted source through the backend's revision compare-and-swap, then reopen it; a concurrent change discards the decoded result and restarts from the new source. The [session-log versioning Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns the rationale and refusal rules. -A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports the step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step validates every accepted vN header/event variant, preserves id and cwd, returns header version N+1, and keeps any cross-event state inside its iterator. Backends and the coordinator remain version-independent. +A future vN→vN+1 change adds `src/format-migrations/vN-to-vN+1.ts`, exports its class from the static `SESSION_FORMAT_MIGRATIONS` array, and increments `SESSION_FORMAT_VERSION`. Static `from`/`to` identify adjacent versions; instance fields retain header and cross-event state. `header()` validates and converts the old header, `event()` returns exactly one lossless-JSON event with the input event's seq, and optional `finish()` validates state that can be settled only at EOF. Header-only reads do not call `finish()`. A migration that changes facts consumed by a projection also increments that projection's `stateVersion`; persistence does not invalidate every projection cache entry. Backends and the coordinator remain version-independent. -The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not version steps. +The v0 decoder also recognizes the bounded pre-versioning variants recorded by the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, and normalizes the historical `compact/start`, `compact/summary`, `compact/end`, and `compact/prune` names to their canonical `compaction/*` names. These compatibility transforms are not format migrations. When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 4ef93f41ac..02010582fd 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -18,7 +18,7 @@ | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 | | `load(id): Promise<{ meta; events }>` | 沿受支持的格式路径解码,并提交格式替换与冷恢复后,返回不可变、平衡的逻辑日志。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式步骤时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。当前格式读取向后端请求 suffix;存在格式迁移时则读取完整 source,迁移后才应用 `fromSeq`。顺序介质可能仍需扫描物理 framing 后再过滤,可寻址介质则可不读取更早的记录。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | @@ -39,11 +39,11 @@ ## 格式解码与升级 -每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,以流式方式执行各个 `migrateEvents()` 转换,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 +每次逻辑读取都会打开可重复使用的 `StoredSessionSource`,其中包含不可信 header、精确 revision 和 `readEvents()` factory。静态 decoder 选择完整的相邻版本路径,为每个版本创建一个 migration 实例,调用一次 `header()`,为每条输入记录调用一次 `event()`,并在 EOF 后调用可选的 `finish()`,最后按当前格式验证 header 与事件。`inspect()` 和 `readFrom()` 不写存储;冷 continuation 与实时接管通过后端的 revision compare-and-swap 替换已转换 source,然后重新打开。并发变更会丢弃解码结果,并从新 source 重新开始。[Session log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)规定其原因和拒绝规则。 -以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加步骤,从静态 `SESSION_FORMAT_STEPS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。该步骤验证它接受的所有 vN header/event 变体,保持 id 与 cwd 不变,返回 version 为 N+1 的 header,并把跨事件状态保留在自己的 iterator 中。后端和协调器不增加版本特判。 +以后新增 vN→vN+1 时,在 `src/format-migrations/vN-to-vN+1.ts` 添加 class,从静态 `SESSION_FORMAT_MIGRATIONS` 数组导出,并递增 `SESSION_FORMAT_VERSION`。静态 `from`/`to` 标识相邻版本,实例字段保留 header 和跨事件状态。`header()` 验证并转换旧 header;`event()` 只返回一条可无损表示为 JSON 且 seq 与输入相同的事件;可选的 `finish()` 验证只能在 EOF 时结算的状态。只读 header 时不调用 `finish()`。如果 migration 改变了某个 projection 消费的事实,还要递增该 projection 的 `stateVersion`;persistence 不统一作废所有 projection cache 记录。后端和协调器不增加版本特判。 -v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是版本步骤。 +v0 decoder 还识别[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所限定的版本机制建立前变体,并将历史 `compact/start`、`compact/summary`、`compact/end`、`compact/prune` 名称归一化为规范的 `compaction/*` 名称。这些兼容转换不是格式迁移。 活动会话发出 `session/disposed` 时,协调器等待其 controller,以串行方式执行最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在活动会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index b5e38c4e98..19eb383a03 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -19,37 +19,45 @@ import { import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' -import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts' -import { SessionPersistenceRevisionConflictError, type SessionPersistenceRevision } from './revision.ts' +import { SESSION_FORMAT_MIGRATIONS } from './format-migrations/index.ts' +import type { SessionPersistenceRevision } from './revision.ts' -/** Stable facts available to one format step invocation. */ -export interface SessionFormatContext { - /** Session identity read from the source header. */ - readonly sessionId: SessionId +/** One single-use adjacent-version migration instance. */ +interface SessionFormatMigrationInstance { + /** + * Transform and validate the header fields understood by this migration. + * The detached result must carry the constructor's `to` version and preserve + * the source id and cwd. + * @param meta - detached input header for the constructor's `from` version. + * @returns detached header JSON carrying the constructor's `to` version. + */ + header(meta: unknown): unknown + /** + * Transform exactly one event while retaining its sequence number. Instance + * fields may accumulate facts from the header and earlier events. + * @param event - detached input event in durable sequence order. + * @returns exactly one detached event for the same sequence number. + */ + event(event: unknown): unknown + /** + * Validate accumulated state after the complete input stream reaches EOF. + * Header-only reads do not call this method; it cannot emit another event. + */ + finish?(): void } -/** One static adjacent-version transform in the durable format decoder. */ -export interface SessionFormatStep { +/** Static identity and constructor for one adjacent-version migration. */ +export interface SessionFormatMigration { /** Input Session format version. */ readonly from: number /** Output Session format version; must equal `from + 1`. */ readonly to: number /** - * Transform and validate the header fields understood by this step. The - * detached result must carry {@link to} and preserve the source id and cwd. - * @param meta - detached input header for {@link from}. - * @param context - stable session identity. - * @returns detached header JSON carrying {@link to}. + * 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. */ - migrateHeader(meta: unknown, context: SessionFormatContext): unknown - /** - * Lazily transform and validate events understood by this step. The output - * must be detached, losslessly JSON-serializable, and contiguous by seq. - * @param events - detached input records in durable sequence order. - * @param context - stable session identity. - * @returns a lazy output stream in the next format. - */ - migrateEvents(events: AsyncIterable, context: SessionFormatContext): AsyncIterable + new(): SessionFormatMigrationInstance } /** Options for one physical event read. */ @@ -123,7 +131,7 @@ export function createStoredEventRead( export interface DecodedSession { /** Validated current-format header. */ readonly meta: SessionHeader - /** Version observed before any format step ran. */ + /** Version observed before any format migration ran. */ readonly sourceVersion: number /** Exact backend revision represented by this source. */ readonly revision: SessionPersistenceRevision @@ -161,33 +169,37 @@ export function sessionFormatVersionRefusal(id: string, version: number): string : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` } -function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap { - const byFrom = new Map() - for (const step of steps) { - if (!Number.isSafeInteger(step.from) || step.from < 0 || step.to !== step.from + 1) { - throw new TypeError(`Session format step must be an adjacent non-negative version, got v${step.from} -> v${step.to}`) +function buildMigrationIndex( + migrations: readonly SessionFormatMigration[], +): ReadonlyMap { + const byFrom = new Map() + for (const Migration of migrations) { + if (!Number.isSafeInteger(Migration.from) || Migration.from < 0 || Migration.to !== Migration.from + 1) { + throw new TypeError(`Session format migration must be an adjacent non-negative version, got v${Migration.from} -> v${Migration.to}`) } - if (byFrom.has(step.from)) { - throw new TypeError(`duplicate Session format step from v${step.from}`) + if (byFrom.has(Migration.from)) { + throw new TypeError(`duplicate Session format migration from v${Migration.from}`) } - if (step.to > SESSION_FORMAT_VERSION) { - throw new TypeError(`Session format step v${step.from} -> v${step.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) + if (Migration.to > SESSION_FORMAT_VERSION) { + throw new TypeError(`Session format migration v${Migration.from} -> v${Migration.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) } - byFrom.set(step.from, step) + byFrom.set(Migration.from, Migration) } - // A missing step is a per-session concern, decided by planSteps() at decode + // A missing migration is a per-session concern, decided by planMigrations() at decode // time: it refuses sessions at or below the gap, while later versions whose // path to the current version is complete still upgrade. Initialization - // therefore checks only step legality and duplicates here. + // therefore checks only migration legality and duplicates here. return byFrom } -const STEP_BY_FROM = buildStepIndex(SESSION_FORMAT_STEPS) +const MIGRATION_BY_FROM = buildMigrationIndex(SESSION_FORMAT_MIGRATIONS) + +type PlannedMigration = readonly [SessionFormatMigration, SessionFormatMigrationInstance] interface DecodedHeader { readonly meta: SessionHeader readonly sourceVersion: number - readonly steps: readonly SessionFormatStep[] + readonly migrations: readonly PlannedMigration[] readonly unversionedCompatibility?: UnversionedFormatCompatibility } @@ -229,23 +241,23 @@ function readSourceHeader( return { meta, version, id } } -function planSteps( +function planMigrations( source: StoredHeaderSource, id: SessionId, fromVersion: number, -): readonly SessionFormatStep[] { - const steps: SessionFormatStep[] = [] +): readonly SessionFormatMigration[] { + const migrations: SessionFormatMigration[] = [] for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) { - const step = STEP_BY_FROM.get(version) - if (step === undefined) { + const Migration = MIGRATION_BY_FROM.get(version) + if (Migration === undefined) { throw unsupported( source, `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`, ) } - steps.push(step) + migrations.push(Migration) } - return steps + return migrations } function decodeHeader( @@ -253,35 +265,37 @@ function decodeHeader( expectedId: SessionId, ): DecodedHeader { const stored = readSourceHeader(source, expectedId) - const steps = planSteps(source, stored.id, stored.version) + const migrations: PlannedMigration[] = [] let meta: unknown = stored.meta - for (const step of steps) { - const context: SessionFormatContext = { sessionId: stored.id } + for (const Migration of planMigrations(source, stored.id, stored.version)) { + let instance: SessionFormatMigrationInstance try { - meta = snapshotJsonValue(step.migrateHeader(meta, context)) + instance = new Migration() + meta = snapshotJsonValue(instance.header(meta)) } catch (error: unknown) { throw new Error( - `session "${stored.id}" header migration v${step.from} -> v${step.to} failed`, + `session "${stored.id}" header migration v${Migration.from} -> v${Migration.to} failed`, { cause: error }, ) } const record = asStoredRecord(meta) const actual = record?.['version'] - if (actual !== step.to) { - throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`) + if (actual !== Migration.to) { + throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} returned header version ${String(actual)}`) } if (record === undefined || record['id'] !== stored.id || record['cwd'] !== stored.meta['cwd']) { - throw new Error(`Session format step v${step.from} -> v${step.to} changed session storage identity`) + throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} changed session storage identity`) } + migrations.push([Migration, instance]) } const current = Session.create(stored.id, undefined, meta as SessionHeader).header const compatibility = unversionedFormatCompatibility(stored.version) return { meta: current, sourceVersion: stored.version, - steps, + migrations, ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }), } } @@ -339,28 +353,41 @@ async function* decodeCurrentEvents( } } -function transformEvents( +async function* transformEvents( events: AsyncIterable, - steps: readonly SessionFormatStep[], + migrations: readonly PlannedMigration[], id: SessionId, ): AsyncIterable { - let transformed = events - for (const step of steps) { - const input = transformed - const context: SessionFormatContext = { sessionId: id } - transformed = (async function* (): AsyncIterable { + for await (let value of events) { + for (const [Migration, instance] of migrations) { + const sourceSeq = asStoredRecord(value)?.['seq'] + let output: unknown try { - yield* step.migrateEvents(input, context) + output = instance.event(value) } catch (error: unknown) { - if (error instanceof SessionPersistenceRevisionConflictError) throw error throw new Error( - `session "${id}" event migration v${step.from} -> v${step.to} failed`, + `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`, { cause: error }, ) } - })() + const targetSeq = asStoredRecord(output)?.['seq'] + if (targetSeq !== sourceSeq) { + throw new Error(`session "${id}" event migration v${Migration.from} -> v${Migration.to} changed event seq ${String(sourceSeq)} to ${String(targetSeq)}`) + } + value = output + } + yield value + } + for (const [Migration, instance] of migrations) { + try { + instance.finish?.() + } catch (error: unknown) { + throw new Error( + `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at EOF`, + { cause: error }, + ) + } } - return transformed } async function* snapshotStoredEvents( @@ -385,7 +412,7 @@ function decodedRead( readonly completed: Promise> } { const completion = Promise.withResolvers>() - const migrating = header.steps.length > 0 + const migrating = header.migrations.length > 0 const compatibility = header.unversionedCompatibility let physical: StoredEventRead | undefined @@ -420,7 +447,7 @@ function decodedRead( : compatibility.canonicalizeEvents(storedEvents, header.meta.id) const transformed = transformEvents( canonicalEvents, - header.steps, + header.migrations, header.meta.id, ) const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq) @@ -438,9 +465,9 @@ function decodedRead( } /** - * Decode one backend source through the static adjacent-version - * steps and the current header/event validators. Format selection is complete - * before any consumer-specific recovery runs. + * Decode one backend source through the static adjacent-version migrations and + * the current header/event validators. Format selection is complete before any + * consumer-specific recovery runs. * @param source - backend-owned header, revision, and event reader factory. * @param expectedId - session identity selected by the caller. * @param fromSeq - first current-format event sequence to return. diff --git a/packages/session/session-persistence/src/format-migrations/index.ts b/packages/session/session-persistence/src/format-migrations/index.ts index ace5670d45..ecdce5e841 100644 --- a/packages/session/session-persistence/src/format-migrations/index.ts +++ b/packages/session/session-persistence/src/format-migrations/index.ts @@ -1,6 +1,6 @@ -/** Static adjacent-version Session format steps shipped by this build. */ +/** Static adjacent-version Session format migrations shipped by this build. */ -import type { SessionFormatStep } from '../format-decoder.ts' +import type { SessionFormatMigration } from '../format-decoder.ts' -/** Ordered durable format steps; format v0 is current, so the chain is empty. */ -export const SESSION_FORMAT_STEPS: readonly SessionFormatStep[] = Object.freeze([]) +/** Ordered durable format migrations; format v0 is current, so the chain is empty. */ +export const SESSION_FORMAT_MIGRATIONS: readonly SessionFormatMigration[] = Object.freeze([]) diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 1fd2c3a73d..91cabdaf99 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -259,8 +259,7 @@ export abstract class SessionPersistence extends Service { export default SessionPersistence export type { - SessionFormatContext, - SessionFormatStep, + SessionFormatMigration, StoredEventRead, StoredEventReadCompletion, StoredEventReadOptions, diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 30004d4f76..5ba23fab17 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -6,8 +6,7 @@ import { SessionPersistenceRevisionConflictError, } from '../src/revision.ts' import type { - SessionFormatContext, - SessionFormatStep, + SessionFormatMigration, StoredEventReadCompletion, StoredSessionSource, } from '../src/format-decoder.ts' @@ -15,6 +14,7 @@ import { sessionFormatVersionRefusal } from '../src/format-decoder.ts' import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts' const id = SessionId('format-migration') +type SessionFormatMigrationInstance = InstanceType function eventLog(): SessionEvent[] { return [ @@ -72,47 +72,69 @@ function storedSource( } } +function defineMigration( + from: number, + create: () => SessionFormatMigrationInstance, + to = from + 1, +): SessionFormatMigration { + return class implements SessionFormatMigrationInstance { + static readonly from = from + static readonly to = to + + private readonly delegate = create() + + header(meta: unknown): unknown { + return this.delegate.header(meta) + } + + event(value: unknown): unknown { + return this.delegate.event(value) + } + + finish(): void { + this.delegate.finish?.() + } + } +} + function migration( from: number, calls: string[], -): SessionFormatStep { - return { - from, - to: from + 1, - migrateHeader(meta) { - calls.push(`header:${from}`) - return { ...(meta as Record), version: from + 1 } - }, - migrateEvents(events) { - return (async function* (): AsyncIterable { - let observedInput = false - for await (const value of events) { - if (!observedInput) { - calls.push(`events:${from}`) - observedInput = true - } - const event = value as SessionEvent - const data = event.data as Record - const migrationPath = Array.isArray(data['migrationPath']) - ? data['migrationPath'] as unknown[] - : [] - yield { - ...event, - data: { - ...data, - [`migratedFrom${from}`]: true, - migrationPath: [...migrationPath, from], - }, - } + to = from + 1, +): SessionFormatMigration { + return defineMigration(from, () => { + let observedInput = false + return { + header(meta) { + calls.push(`header:${from}`) + return { ...(meta as Record), version: to } + }, + event(value) { + if (!observedInput) { + calls.push(`events:${from}`) + observedInput = true } - })() - }, - } + const event = value as SessionEvent + const data = event.data as Record + const migrationPath = Array.isArray(data['migrationPath']) + ? data['migrationPath'] as unknown[] + : [] + return { + ...event, + data: { + ...data, + [`migratedFrom${from}`]: true, + migrationPath: [...migrationPath, from], + }, + } + }, + } + }, to) } async function configuredDecoder( currentVersion: number, - migrations: readonly SessionFormatStep[], + migrations: readonly SessionFormatMigration[], calls: string[] = [], ): Promise<{ decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession'] @@ -143,7 +165,7 @@ async function configuredDecoder( } }) vi.doMock('../src/format-migrations/index.ts', () => ({ - SESSION_FORMAT_STEPS: migrations, + SESSION_FORMAT_MIGRATIONS: migrations, })) const decoder = await import('../src/format-decoder.ts') return { @@ -196,21 +218,20 @@ describe('versioned Session format decoder', { concurrent: false }, () => { }) it('lets an old-format suffix migration use facts from events before fromSeq', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - let previousSeq: number | undefined - for await (const value of events) { + const step = defineMigration(0, () => { + let previousSeq: number | undefined + return { + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { const event = value as SessionEvent - yield previousSeq === undefined + const migrated = previousSeq === undefined ? event : { ...event, data: { ...event.data, previousSeq } } previousSeq = event.seq - } - })(), - } + return migrated + }, + } + }) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, eventLog()) @@ -329,34 +350,59 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(events[0]?.data).toMatchObject({ migrationPath: [1] }) }) - it('passes the stable stored identity to every header and event step', async () => { - const contexts: SessionFormatContext[] = [] - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader(meta, context) { - contexts.push(context) - return { ...(meta as Record), version: 1 } - }, - migrateEvents(events, context) { - contexts.push(context) - return events - }, - } - const { decodeStoredSession } = await configuredDecoder(1, [step]) + it('retains instance state from the header through events and finishes at EOF', async () => { + const calls: string[] = [] + const Migration = defineMigration(0, () => { + let headerId: SessionId | undefined + let migratedEvents = 0 + return { + header(meta) { + calls.push('header') + headerId = SessionId((meta as Record)['id'] as string) + return { ...(meta as Record), version: 1 } + }, + event(value) { + calls.push(`event:${migratedEvents}`) + migratedEvents += 1 + return { + ...(value as SessionEvent), + data: { ...(value as SessionEvent).data, headerId, migratedEvents }, + } + }, + finish() { + calls.push(`finish:${migratedEvents}`) + }, + } + }) + const { decodeStoredSession } = await configuredDecoder(1, [Migration]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) - await collectEvents(decoded.events) + expect(calls).toEqual(['header']) + const events = await collectEvents(decoded.events) await decoded.completed - expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }]) + expect(calls).toEqual(['header', 'event:0', 'event:1', 'finish:2']) + expect(events.map(event => event.data)).toMatchObject([ + { headerId: id, migratedEvents: 1 }, + { headerId: id, migratedEvents: 2 }, + ]) }) it('migrates and validates a header without requiring an event source', async () => { const calls: string[] = [] + const first = defineMigration(0, () => ({ + header(meta) { + calls.push('header:0') + return { ...(meta as Record), version: 1 } + }, + event: value => value, + finish() { + calls.push('finish:0') + }, + })) const { decodeStoredSessionHeader } = await configuredDecoder( 2, - [migration(0, calls), migration(1, calls)], + [first, migration(1, calls)], calls, ) @@ -366,18 +412,35 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(calls).toEqual(['header:0', 'header:1', 'validate-header']) }) - it('applies event migration before the current event vocabulary check', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as Record - yield { ...event, type: 'turn/start', data: { turn: 1 } } - } - })(), + it('allows a migration instance without finish', async () => { + class MigrationWithoutFinish implements SessionFormatMigrationInstance { + static readonly from = 0 + static readonly to = 1 + + header(meta: unknown): unknown { + return { ...(meta as Record), version: 1 } + } + + event(value: unknown): unknown { + return value + } } + const { decodeStoredSession } = await configuredDecoder(1, [MigrationWithoutFinish]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + + await expect(collectEvents(decoded.events)).resolves.toEqual(eventLog()) + await expect(decoded.completed).resolves.toEqual({}) + }) + + it('applies event migration before the current event vocabulary check', async () => { + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as Record + return { ...event, type: 'turn/start', data: { turn: 1 } } + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, [ { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } }, @@ -750,13 +813,11 @@ describe('versioned Session format decoder', { concurrent: false }, () => { await expect(decoded.completed).resolves.toEqual({}) }) - it('rejects a step that returns the wrong header version', async () => { - const bad: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 0 }), - migrateEvents: events => events, - } + it('rejects a migration that returns the wrong header version', async () => { + const bad = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 0 }), + event: value => value, + })) const first = await configuredDecoder(1, [bad]) expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) @@ -764,12 +825,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(first.validateHeader).not.toHaveBeenCalled() const calls: string[] = [] - const badSecond: SessionFormatStep = { - from: 1, - to: 2, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => events, - } + const badSecond = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: value => value, + })) const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls) const stored = storedSource(0, []) expect(() => second.decodeStoredSession(stored.source, id)) @@ -779,23 +838,19 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(stored.reads).toEqual([]) }) - it('rejects a step that changes the session id or cwd storage identity', async () => { - const changedId: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1, id: 'other' }), - migrateEvents: events => events, - } + it('rejects a migration that changes the session id or cwd storage identity', async () => { + const changedId = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1, id: 'other' }), + event: value => value, + })) const first = await configuredDecoder(1, [changedId]) expect(() => first.decodeStoredSession(storedSource(0, []).source, id)) .toThrow(/changed session storage identity/) - const changedCwd: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), - migrateEvents: events => events, - } + const changedCwd = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1, cwd: '/other' }), + event: value => value, + })) const second = await configuredDecoder(1, [changedCwd]) const stored = storedSource(0, []) stored.meta['cwd'] = '/work' @@ -805,12 +860,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('wraps a header migration failure with the failing version step', async () => { const cause = new Error('bad legacy header') - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: () => { throw cause }, - migrateEvents: events => events, - } + const step = defineMigration(0, () => ({ + header: () => { throw cause }, + event: value => value, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) let failure: unknown @@ -827,14 +880,10 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('mirrors an event migration failure through the stream and completion promise', async () => { const cause = new Error('bad legacy event') - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: () => (async function* (): AsyncIterable { - throw cause - })(), - } + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: () => { throw cause }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) const completion = decoded.completed.catch((error: unknown) => error) @@ -843,23 +892,35 @@ describe('versioned Session format decoder', { concurrent: false }, () => { const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) expect(streamFailure).toBe(completionFailure) expect(streamFailure).toMatchObject({ - message: `session "${id}" event migration v0 -> v1 failed`, + message: `session "${id}" event migration v0 -> v1 failed at seq 0`, cause, }) }) - it('runs current event validation on the migrated output', async () => { - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader: meta => ({ ...(meta as Record), version: 1 }), - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as SessionEvent - yield { ...event, seq: event.seq + 1 } - } - })(), - } + it('mirrors a finish failure through the stream and completion promise', async () => { + const cause = new Error('unclosed legacy state') + const Migration = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event: value => value, + finish: () => { throw cause }, + })) + const { decodeStoredSession } = await configuredDecoder(1, [Migration]) + + const failure = await decodedFailure(decodeStoredSession(storedSource(0, eventLog()).source, id)) + expect(failure).toMatchObject({ + message: `session "${id}" event migration v0 -> v1 failed at EOF`, + cause, + }) + }) + + it('rejects a migration that changes an event sequence number', async () => { + const step = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + return { ...event, seq: event.seq + 1 } + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) const completion = decoded.completed.catch((error: unknown) => error) @@ -867,21 +928,30 @@ describe('versioned Session format decoder', { concurrent: false }, () => { const [streamFailure, completionFailure] = await Promise.all([consumption, completion]) expect(streamFailure).toBe(completionFailure) - expect((streamFailure as Error).message).toMatch(/expected 0, got 1/) + expect((streamFailure as Error).message).toMatch(/changed event seq 0 to 1/) + }) + + it('rejects a non-contiguous current-format event sequence', async () => { + const { decodeStoredSession } = await configuredDecoder(0, []) + const stored = storedSource(0, [ + { type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }, + ]) + + const failure = await decodedFailure(decodeStoredSession(stored.source, id)) + + expect(failure.message).toContain(`session "${id}" event seq mismatch: expected 0, got 1`) }) it('runs current header validation only after the final header step', async () => { const calls: string[] = [] - const finalStep: SessionFormatStep = { - from: 1, - to: 2, - migrateHeader(meta) { + const finalStep = defineMigration(1, () => ({ + header(meta) { calls.push('header:1') const { createdAt: _createdAt, ...rest } = meta as Record return { ...rest, version: 2 } }, - migrateEvents: events => events, - } + event: value => value, + })) const { decodeStoredSession } = await configuredDecoder( 2, [migration(0, calls), finalStep], @@ -898,23 +968,19 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('detaches stored header and event objects before a mutating migration runs', async () => { const originalEvents = eventLog() const eventSnapshot = structuredClone(originalEvents) - const step: SessionFormatStep = { - from: 0, - to: 1, - migrateHeader(meta) { + const step = defineMigration(0, () => ({ + header(meta) { const record = meta as Record record['version'] = 1 return record }, - migrateEvents: events => (async function* (): AsyncIterable { - for await (const value of events) { - const event = value as SessionEvent - const data = event.data as Record - data['mutated'] = true - yield event - } - })(), - } + event(value) { + const event = value as SessionEvent + const data = event.data as Record + data['mutated'] = true + return event + }, + })) const { decodeStoredSession } = await configuredDecoder(1, [step]) const stored = storedSource(0, originalEvents) @@ -930,23 +996,16 @@ describe('versioned Session format decoder', { concurrent: false }, () => { it('rejects duplicate, invalid, and future-targeting static registries at initialization', async () => { const calls: string[] = [] await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)])) - .rejects.toThrow(/duplicate Session format step/) + .rejects.toThrow(/duplicate Session format migration/) await expect(configuredDecoder(1, [migration(-1, calls)])) .rejects.toThrow(/adjacent non-negative version/) - const nonAdjacent: SessionFormatStep = { - ...migration(0, calls), - to: 2, - } + const nonAdjacent = migration(0, calls, 2) await expect(configuredDecoder(2, [nonAdjacent])) .rejects.toThrow(/adjacent non-negative version/) - const fractional: SessionFormatStep = { - ...migration(0, calls), - from: 0.5, - to: 1.5, - } + const fractional = migration(0.5, calls, 1.5) await expect(configuredDecoder(2, [fractional])) .rejects.toThrow(/adjacent non-negative version/) From 7e3d5332dc305f06289ef103c3b34cc8ed77ac8a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 17:48:45 +0800 Subject: [PATCH 11/37] fix(session): address migration review feedback --- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 26 +++++++- docs/subsystems/persistence.zh.md | 26 +++++++- packages/core/session/src/types.ts | 6 +- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 16 +++-- .../tests/jsonl.spec.ts | 17 +++++ .../session-persistence/src/format-decoder.ts | 15 +++-- .../tests/format-decoder.spec.ts | 63 +++++++++++++++++++ scripts/type-equiv.manifest.json | 5 ++ 12 files changed, 162 insertions(+), 24 deletions(-) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 886f483490..57476a2fb6 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: 1480780b343e2d55544e441362abde58ffdffb2b -persistence.zh.md: 67bd8900fbcd0d006adb80da13fa8dbb2b6dd3e0 +persistence.md: c08ac3e37a34678b3731a251f727e1648e00211e +persistence.zh.md: 91d8b2549cb78f5fb4d00be63b4c7e787b910fad diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 1480780b34..c08ac3e37a 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 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 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 67bd8900fb..91d8b2549c 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/core/session/src/types.ts b/packages/core/session/src/types.ts index 31ce28a01b..3c12f5ee07 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -35,7 +35,7 @@ export function SessionId(id: string): SessionId { * and enforced by every persistence backend on load. The single source of truth for the * version — write sites and the load-time check all read it. * While the harness is unreleased it is pinned at `0`: no compatibility is - * implied, incompatible logs are rejected, and no migration is provided. + * implied; older logs load only through a complete adjacent migration path. * * The version is a single monotonic integer with no major/minor split. Whether * a bump is needed is decided by what the WRITER emits, never by what a newer @@ -61,8 +61,8 @@ export const SESSION_FORMAT_VERSION = 0 export 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). */ diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index b4401a1dc3..3501fa53df 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: 00f893c134133207a1e9a12397c996c7c6c0c76c -README.zh.md: 800eaf6a8d758a18ac3bbecb87644b4f95838119 +README.md: 73e503d8f6741e84ee50be8cc4cbfbf2c07babc8 +README.zh.md: 9e778cb155f9f1d964bc9592057ae39bb0afb8cb diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 00f893c134..73e503d8f6 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -35,7 +35,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation. -A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format steps can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. +A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `/.jsonl*` artifacts are also rejected instead of ignored. Session format migrations can replace a logical log within its configured encoding; there is no compression migration, mixed-root fallback, or dual write. ## Durability and crash semantics diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 800eaf6a8d..9e778cb155 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -35,7 +35,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d 默认产物是独立 [Zstandard frame](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.zh.md) 的标准拼接:一个仅包含 header 行的带 checksum frame,后跟每个持久 append 批次一个带 checksum frame。后端使用 Node 内置 Zstandard API 和默认压缩级别,不提供级别开关。列表只读取并验证 header frame。`compression: 'none'` 在原始表示中保留相同逻辑行。 -一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式步骤可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 +一个根只属于一种编码。启动发现和定向查找会拒绝相反 suffix,错误会命名不兼容产物,并指示调用方选择匹配 mode 或独立根。平铺 `/.jsonl*` 产物也会被拒绝,而不是忽略。Session 格式迁移可以在已配置编码内替换逻辑日志;不提供压缩迁移、混合根回退或双写。 ## 持久性与崩溃语义 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index d4d8e5c19f..a6d222cf5c 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -39,9 +39,9 @@ export type { JsonlCompression } from './format.ts' const DEFAULT_PACK_CHUNKS = true const DEFAULT_COMPRESSION: JsonlCompression = 'zstd' /** - * Internal scheduling constant, not deployment configuration: balance - * frame-boundary event-loop yields against `setImmediate` overhead. One frame - * remains an indivisible synchronous decode. + * Internal scheduling constants, not deployment configuration: decode yields + * balance frame latency against `setImmediate` overhead; replacement batches + * bound memory and frame granularity without changing durable behavior. */ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500 const REPLACEMENT_BATCH_SIZE = 128 @@ -605,8 +605,14 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const rawMeta = parseStoredHeaderMeta(first) if (rawMeta === undefined) continue // not a session header - const identity = this.storedIdentity(rawMeta, path) - const meta = decodeStoredSessionHeader(rawMeta, identity.id, { kind: 'jsonl', path }) + const rawId = typeof rawMeta === 'object' && rawMeta !== null + ? (rawMeta as Record)['id'] + : undefined + const expectedId = typeof rawId === 'string' + ? SessionId(rawId) + : SessionId('') + const meta = decodeStoredSessionHeader(rawMeta, expectedId, { kind: 'jsonl', path }) + this.storedIdentity(rawMeta, path) await this.assertStoredIdentity(path, rawMeta, undefined, signal) signal?.throwIfAborted() if (ids.has(meta.id)) { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index ff88640e34..2a10b14f2f 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1524,6 +1524,23 @@ describe('JsonlSessionPersistence: edge cases', () => { expect(await ctx.sessionPersistence.list()).toEqual([]) }) + it('listing refuses a future format before validating current identity fields', async () => { + const id = SessionId('future-list') + const path = rawLogPath(root, '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) + + for (const list of [ + () => ctx.sessionPersistence.list(), + () => ctx.sessionPersistence.listSnapshots(), + ]) { + const failure = await list().then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('session "123" uses log format v42') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + } + }) + it('keeps the transcript in an extensible session-owned directory', async () => { const m = meta('owned-directory', '/project') await ctx.sessionPersistence.create(m) diff --git a/packages/session/session-persistence/src/format-decoder.ts b/packages/session/session-persistence/src/format-decoder.ts index 19eb383a03..398b487860 100644 --- a/packages/session/session-persistence/src/format-decoder.ts +++ b/packages/session/session-persistence/src/format-decoder.ts @@ -33,8 +33,9 @@ interface SessionFormatMigrationInstance { */ header(meta: unknown): unknown /** - * Transform exactly one event while retaining its sequence number. Instance - * fields may accumulate facts from the header and earlier events. + * Transform exactly one event into detached lossless JSON while retaining + * its sequence number. Instance fields may accumulate facts from the header + * and earlier events. * @param event - detached input event in durable sequence order. * @returns exactly one detached event for the same sequence number. */ @@ -137,7 +138,10 @@ export interface DecodedSession { readonly revision: SessionPersistenceRevision /** Validated current-format events at or past the requested sequence. */ readonly events: AsyncIterable - /** Completion metadata from the physical read supplying the events. */ + /** + * Completion metadata from the physical read supplying the events. Settles + * only after the events iterable is fully consumed or fails. + */ readonly completed: Promise> } @@ -363,7 +367,10 @@ async function* transformEvents( const sourceSeq = asStoredRecord(value)?.['seq'] let output: unknown try { - output = instance.event(value) + output = snapshotJsonValue(instance.event(value)) + if (output === undefined) { + throw new Error('migration returned an event that is not losslessly JSON-serializable') + } } catch (error: unknown) { throw new Error( `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`, diff --git a/packages/session/session-persistence/tests/format-decoder.spec.ts b/packages/session/session-persistence/tests/format-decoder.spec.ts index 5ba23fab17..5b80ac15c6 100644 --- a/packages/session/session-persistence/tests/format-decoder.spec.ts +++ b/packages/session/session-persistence/tests/format-decoder.spec.ts @@ -316,6 +316,69 @@ describe('versioned Session format decoder', { concurrent: false }, () => { expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] }) }) + it('detaches each migration output before the next migration mutates its input', async () => { + const retained: Array> = [] + const first = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + const output = { + ...event, + data: { ...(event.data as Record), first: true }, + } + retained.push(output.data) + return output + }, + })) + const second = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 2 }), + event(value) { + const event = value as SessionEvent + const data = event.data as Record + data['second'] = true + return event + }, + })) + const { decodeStoredSession } = await configuredDecoder(2, [first, second]) + + const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id) + const events = await collectEvents(decoded.events) + await decoded.completed + + expect(events.every(event => (event.data as Record)['second'] === true)).toBe(true) + expect(retained.every(data => data['second'] === undefined)).toBe(true) + }) + + it('rejects a non-JSON event output before a later migration can repair it', async () => { + const first = defineMigration(0, () => ({ + header: meta => ({ ...(meta as Record), version: 1 }), + event(value) { + const event = value as SessionEvent + return { + ...event, + data: { ...(event.data as Record), transient: undefined }, + } + }, + })) + const second = defineMigration(1, () => ({ + header: meta => ({ ...(meta as Record), version: 2 }), + event(value) { + const event = value as SessionEvent + const data = event.data as Record + delete data['transient'] + return event + }, + })) + const { decodeStoredSession } = await configuredDecoder(2, [first, second]) + + const failure = await decodedFailure( + decodeStoredSession(storedSource(0, eventLog()).source, id), + ) + + expect(failure.message).toMatch(/event migration v0 -> v1 failed at seq 0/) + expect((failure.cause as Error).message).toMatch(/not losslessly JSON-serializable/) + }) + it('plans by version even when registry entries are declared out of order', async () => { const calls: string[] = [] const { decodeStoredSession } = await configuredDecoder( diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8b580750bd..2b29010478 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -545,6 +545,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionFormatMigration", + "source": "packages/session/session-persistence/src/format-decoder.ts" + }, { "doc": "docs/subsystems/persistence.md", "symbol": "SessionRawArtifact", From 95ed302c9caf735006cde07f97c93541b67d62f4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 21 Aug 2026 22:08:53 +0800 Subject: [PATCH 12/37] fix(session): remove unreachable listing branch --- packages/session/session-persistence-jsonl/src/format.ts | 2 +- packages/session/session-persistence-jsonl/src/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index bf6d1fde2d..321278088a 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -397,7 +397,7 @@ export function scanLog(buffer: Buffer): SessionLogScan { * @param firstLine - first JSONL record without its newline. * @returns normalized logical header JSON, or `undefined` for invalid framing. */ -export function parseStoredHeaderMeta(firstLine: string): unknown { +export function parseStoredHeaderMeta(firstLine: string): Record | undefined { let parsed: unknown try { parsed = JSON.parse(firstLine) diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index a6d222cf5c..d80979da2e 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -605,9 +605,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi if (first === undefined) continue // empty/half-written file const rawMeta = parseStoredHeaderMeta(first) if (rawMeta === undefined) continue // not a session header - const rawId = typeof rawMeta === 'object' && rawMeta !== null - ? (rawMeta as Record)['id'] - : undefined + const rawId = rawMeta['id'] const expectedId = typeof rawId === 'string' ? SessionId(rawId) : SessionId('') From 3d660d2db2cb68667b1a1804ed6865e947547d6e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 22 Aug 2026 16:26:31 +0800 Subject: [PATCH 13/37] ci: raise host TypeScript heap budget --- package.json | 2 +- scripts/wine-windows-gates.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 391c93d938..86f3e40a24 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build": "tsx scripts/build.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "clean": "tsx scripts/clean.ts", diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 5e46b75f91..f9f04faea0 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -243,7 +243,7 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga # Host face before compiling and bundling the Client face. # Both statuses are captured so one failure cannot hide the other's result. build_gate() { - wine_node "$scratch/logs/host-tsc.log" "$tsc_js" -b tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false || return $? wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? wine_node "$scratch/logs/client-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE client From 65295d5b6854e7da0434919ee3be02807de79f30 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Sat, 22 Aug 2026 21:51:49 +0800 Subject: [PATCH 14/37] docs(session): refresh persistence pairing record --- packages/session/session-persistence/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index a7d636a65b..1605bdbee5 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: 4a3111f8e3d38add9204b121dd100c9fa5a78d7c -README.zh.md: 5d93f952224b31b233986e7e4a90d319edde4e19 +README.md: 323d7b23cff6438264ae4aa4a3fecbd06a832037 +README.zh.md: bb667f6989f1a0df9d258d223f0f6721a233433a From 58a0e450b3d63b2649b8668c46f82d40b609d416 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 24 Aug 2026 11:38:14 +0800 Subject: [PATCH 15/37] perf(token-meter): commit the surface fold in place through a plan/commit pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foldSurfaceTokens rebuilt the priced surface on every surface event: an append allocated [...nodes, node] and a replacement copied the whole array before splicing, charging every well-formed event O(surface) for an atomicity property only malformed events need. Benchmarks put the copy at ~99.9% of an append's cost (100µs at a 50k-node surface vs 0.1µs for pricing) with O(S²) accumulation over a session, inside the synchronous session/event publication path. Split the fold into the session core's planSurfaceEvent/applySurfacePlan shape: planSurfaceTokens performs every fallible step against the read-only surface, commitSurfaceTokens applies the plan in place and is infallible by construction. _foldEvent plans first, runs the remaining fallible anchor validation, and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop to amortized O(1) (100.3µs -> 1.9µs at 50k nodes); replacements keep their O(surface) findIndex but stop paying the extra full copy (21µs -> 4.2µs). A new regression test pins the one hazard this introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures. --- ...n-meter-surface-fold-plan-commit.i18n.yaml | 6 ++ ...24-token-meter-surface-fold-plan-commit.md | 29 ++++++++ ...token-meter-surface-fold-plan-commit.zh.md | 29 ++++++++ ...composer-context-meter-breakdown.i18n.yaml | 4 +- ...-08-05-composer-context-meter-breakdown.md | 4 +- ...-05-composer-context-meter-breakdown.zh.md | 4 +- packages/llm/token-meter/src/index.ts | 20 +++--- packages/llm/token-meter/src/surface-fold.ts | 68 +++++++++++-------- .../llm/token-meter/tests/token-meter.spec.ts | 27 ++++++++ 9 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.i18n.yaml new file mode 100644 index 0000000000..44ae87702b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.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/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md +2026-08-24-token-meter-surface-fold-plan-commit.md: 878b2634c18e6bfbf5c341260659028f92399e96 +2026-08-24-token-meter-surface-fold-plan-commit.zh.md: 7707bf645ecc26098835e6922f48911927ce36f1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md new file mode 100644 index 0000000000..878b2634c1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md @@ -0,0 +1,29 @@ +# Agent Note: Token-meter surface fold commits in place through a plan/commit pair + +Status: implemented + +English | [中文](2026-08-24-token-meter-surface-fold-plan-commit.zh.md) + +## Problem + +`foldSurfaceTokens` rebuilt the meter's priced surface on every surface event: an append allocated `[...nodes, node]` and a replacement copied the whole array before splicing. The copy existed for one property — a throw must leave the caller's `ReplayState` untouched so a malformed event fails identically on every retry — but it charged every WELL-FORMED event O(surface) for it. Benchmarks on this fold showed the copy was ~99.9% of an append's cost (100µs at a 50k-node surface versus 0.1µs for the pricing itself), and successive appends accumulate O(S²) over a session's life, concentrated in exactly the long sessions users report as sluggish. The token meter folds inside the synchronous `session/event` publication path, so this cost lands on the agent loop's streaming appends. + +## Decision + +Split the fold into the session core's existing `planSurfaceEvent`/`applySurfacePlan` shape: `planSurfaceTokens` performs every fallible step (message pricing, replacement-range resolution) against the read-only surface and returns a `SurfaceTokenPlan`; `commitSurfaceTokens` applies a plan in place — `push` for an append, one `splice` for a replacement — and is infallible by construction. `TokenMeter._foldEvent` plans first, runs the remaining fallible anchor validation (step pairing, provider-chunk provenance), and only then commits, so retry identity is preserved by ordering instead of by allocation. Appends drop from O(surface) to amortized O(1); replacements keep their O(surface) `findIndex` but stop paying the extra full copy. + +`measure()` still detaches its result with `structuredClone` + `deepFreeze`, so in-place mutation of the meter-owned array never escapes to callers. + +## Testing + +The existing malformed-replay suite already pins retry identity (`expectRepeatedFailure` asserts the same throw twice for out-of-range replacements, missing step boundaries, and bad provenance). A new regression test covers the hazard this change introduces: an event whose surface plan is valid but whose later anchor validation throws must leave the priced surface and running total uncommitted across repeated failures — under a mis-ordered in-place commit the throw pattern would still match while the surface silently double-counted. The full token-meter and compaction suites exercise both commit arms through real prune and summary replacements. + +## Alternatives considered + +**A seq→index map to make replacements O(1) too.** Rejected for now: index shifts on every splice force an O(surface) rebuild per replacement anyway, and replacements are orders of magnitude rarer than appends (compaction summaries and prune passes only). The append path was the quadratic term. + +**Keeping the allocation and sharing structurally (persistent vector).** Rejected: a dependency or hand-rolled structure for a single internal array is not justified when the plan/commit ordering already provides the atomicity the copy existed for. + +## Consequences + +The fold no longer contributes a quadratic term to long-session append cost; the meter's remaining per-event costs are the `Session.events` snapshot read in `_sync` (addressed independently by the indexed log-read work, PR #1724/#2907) and O(content) pricing, which is inherent. `SurfaceTokenFold` (the old detached-result type) is gone; `surface-fold.ts` is package-internal, so no external consumer changes. The [composer context-meter note](../feature/2026-08-05-composer-context-meter-breakdown.md) records the projection design around this fold. diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md new file mode 100644 index 0000000000..7707bf645e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md @@ -0,0 +1,29 @@ +# Agent Note:token-meter surface fold 改为 plan/commit 两段式原地提交 + +状态:已实现 + +[English](2026-08-24-token-meter-surface-fold-plan-commit.md) | 中文 + +## 问题 + +`foldSurfaceTokens` 在每个 surface 事件上重建计价 surface:append 分配 `[...nodes, node]`,replacement 先整表复制再 splice。这次复制只为一个性质而存在——抛错必须让调用方的 `ReplayState` 保持原样,使同一条畸形事件在每次重试时以完全相同的方式失败——但它让每条**合法**事件都为此付出 O(surface)。针对该 fold 的基准显示复制占 append 成本的约 99.9%(surface 为 5 万节点时每次 100µs,而估价本身仅 0.1µs),且连续 append 在会话生命周期内累计 O(S²),恰好集中在用户反馈卡顿的长会话上。token meter 在同步的 `session/event` 发布路径内折叠,这笔成本直接落在 agent loop 的流式 append 上。 + +## 决定 + +按 session 核心既有的 `planSurfaceEvent`/`applySurfacePlan` 形态拆分 fold:`planSurfaceTokens` 针对只读 surface 执行所有可失败步骤(消息估价、替换区间解析)并返回 `SurfaceTokenPlan`;`commitSurfaceTokens` 原地应用 plan——append 用 `push`,replacement 用一次 `splice`——并且构造上不可失败。`TokenMeter._foldEvent` 先 plan,再执行剩余的可失败 anchor 校验(step 配对、provider chunk 溯源),最后才 commit,因此重试一致性由执行顺序保证而不再依赖分配。append 从 O(surface) 降为均摊 O(1);replacement 保留 O(surface) 的 `findIndex`,但不再额外整表复制。 + +`measure()` 仍以 `structuredClone` + `deepFreeze` 分离结果,所以对 meter 私有数组的原地修改永远不会泄漏给调用方。 + +## 测试 + +既有的畸形回放套件已钉住重试一致性(`expectRepeatedFailure` 对越界替换、缺失 step 边界、坏溯源各断言两次相同抛错)。新增一个回归测试覆盖本次改动引入的风险点:surface plan 合法但后续 anchor 校验抛错的事件,必须在反复失败后保持计价 surface 与累计总量未提交——若原地提交顺序错误,抛错模式依然匹配而 surface 会悄悄重复计数。完整的 token-meter 与 compaction 套件通过真实的 prune 与 summary 替换覆盖两个 commit 分支。 + +## 曾考虑的替代方案 + +**用 seq→index 映射把 replacement 也做成 O(1)。** 暂缓:每次 splice 引起的下标移动本就迫使映射按替换做 O(surface) 重建,而 replacement 比 append 少几个数量级(仅 compaction 摘要与 prune 批次)。二次项在 append 路径上。 + +**保留分配并用结构共享(持久化向量)。** 否决:为单个内部数组引入依赖或手搓结构并不划算,plan/commit 的顺序已提供复制原本换取的原子性。 + +## 后果 + +该 fold 不再为长会话 append 成本贡献二次项;meter 剩余的每事件成本是 `_sync` 中的 `Session.events` 快照读取(由索引化日志读取工作独立解决,PR #1724/#2907)与固有的 O(内容) 估价。旧的分离结果类型 `SurfaceTokenFold` 已移除;`surface-fold.ts` 为包内部模块,无外部消费者需要变更。[composer 上下文仪表笔记](../feature/2026-08-05-composer-context-meter-breakdown.zh.md)记录了该 fold 周边的投影设计。 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml index f3a45e4457..876378ec58 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.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-08-05-composer-context-meter-breakdown.md -2026-08-05-composer-context-meter-breakdown.md: a757bcbc8bc57f4c3a16f663a9c922155b8bb575 -2026-08-05-composer-context-meter-breakdown.zh.md: 02fcfdf89664dcf932509a0d56193bb4e0d83805 +2026-08-05-composer-context-meter-breakdown.md: 318a1caf7d2494baa9ee72e2719efc22523fdac6 +2026-08-05-composer-context-meter-breakdown.zh.md: 886108dd9cb197afbfc210e3bdbf1f42cf4b1f8f diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md index a757bcbc8b..318a1caf7d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md @@ -14,7 +14,7 @@ Three cooperating pieces, one per package boundary: `dsh-session` exports the pure `deriveEventMessage(event)` (previously reachable only as a `Session` method, which now delegates to it) so a host-side fold can price surface nodes without a `Session` instance. -`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` and its positional surface fold into `src/surface-fold.ts` — both shared verbatim with the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure replays `foldSurfaceTokens` over a per-node `{seq, tokens}` list, so it equals `measure().surfaceTokens` at every event boundary by construction and compaction shrinks it the way it shrinks the next request. The shared fold is total and allocation-fresh — it returns the next surface rather than mutating one — which keeps the service's validate-before-commit replay transaction intact: a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. +`dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` (shared verbatim with the measurement service) and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure rides the O(1) shadow-price fold in `src/surface-projection.ts`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it by its logged shadow price. The measurement service's own positional fold lives in `src/surface-fold.ts` as a plan/commit pair ([in-place surface commit](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.md)): a throw leaves the replay cursor unmoved and the same malformed event fails identically on retry, and a replace range absent from the folded surface throws — committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event. `ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. (The ring, header, and bar length were provider-exact as shipped here; they now read the provider-anchored `projectedTokens` instead, because the bare sample could not see a compaction — see [the meter's compaction blindness](../bug-fix/2026-08-05-context-meter-blind-to-compaction.md).) The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy. @@ -28,4 +28,4 @@ Three cooperating pieces, one per package boundary: ## Consequences -Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 1). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. +Token-meter now registers three projection keys; unloading removes all three, and `contextBreakdown` restores from JSON checkpoints (`stateVersion` 2). The stats line dropped its Context group and the ring is the sole context UI. The panel's heuristic rows visibly disagree with the provider-exact header — accepted and signposted by the `~` prefix; improving estimate accuracy (for example CJK-aware weighting) is localized to `estimate.ts` and changes no seam. The legend's purple segment tint is a literal color because the design platform ships no purple static token. diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md index 02fcfdf896..886108dd9c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md @@ -14,7 +14,7 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N `dsh-session` 导出纯函数 `deriveEventMessage(event)`(此前只能通过 `Session` 方法访问,该方法现在委托给它),使 host 侧 fold 无需 `Session` 实例即可为表层节点计价。 -`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`、把位置表层折叠抽取到 `src/surface-fold.ts`(两者都与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字在逐节点 `{seq, tokens}` 列表上重放 `foldSurfaceTokens`,因此它在每个事件边界上按构造等于 `measure().surfaceTokens`,压缩(compaction)会像缩小下一个请求那样缩小它。这份共享折叠是全函数且总是新建数组——返回下一个表层而不是原地改写——从而保留了服务侧「先校验再提交」的重放事务:抛出时重放游标不前进,同一条畸形事件在重试时报同样的错。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 +`dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`(与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字搭载 `src/surface-projection.ts` 的 O(1) 影子价折叠,因此在完整计量的日志上它在每个事件边界等于 `measure().surfaceTokens`,压缩(compaction)按已记录的影子价缩小它。测量服务自己的位置折叠位于 `src/surface-fold.ts`,是 plan/commit 两段式([原地表层提交](../bug-fix/2026-08-24-token-meter-surface-fold-plan-commit.zh.md)):抛出时重放游标不前进,同一条畸形事件在重试时报同样的错;折叠表层中不存在的替换范围会直接抛出——已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。 `ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——启发式数字只决定进度条各彩色分段之间的相对比例,并原样显示在明细行中;每个数字都标有 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。(本记录落地时,圆环、标题与进度条总长取的是提供方精确值;它们现在改读锚定在提供方读数上的 `projectedTokens`,因为裸样本看不见压缩——见[仪表对压缩的失明](../bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md)。)标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自身独立的强调样式;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。 @@ -28,4 +28,4 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N ## 后果 -token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 1)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 +token-meter 现在注册三个投影键;卸载会移除全部三个,`contextBreakdown` 可从 JSON 检查点恢复(`stateVersion` 为 2)。统计行删除了 Context 分组,圆环成为唯一的上下文 UI。面板的启发式明细行与提供方精确的标题数字肉眼可见地不一致——已接受并以 `~` 前缀标示;提升估算精度(例如按 CJK 加权)只需改动 `estimate.ts`,不涉及任何 seam。图例的紫色分段色值是字面量,因为设计平台没有紫色静态 token。 diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 2fa53f78f1..f0a0a404ef 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -21,7 +21,7 @@ import type { import { contextBreakdownProjectionDefinition } from './breakdown-projection.ts' import { contextPressureProjectionDefinition, tokenUsageProjectionDefinition } from './usage-projection.ts' import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from './estimate.ts' -import { foldSurfaceTokens } from './surface-fold.ts' +import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts' export type * from './types.ts' @@ -181,9 +181,9 @@ export class TokenMeter extends Service { } /** - * Validate and prepare every fallible part before mutating replay state. - * A malformed event remains unread on every retry instead of partially - * applying the same mutation more than once. + * Run every fallible step — surface plan and anchor validation — before + * mutating replay state, so a malformed event remains unread on every + * retry instead of half-applying. */ private _foldEvent(session: Session, state: ReplayState, event: SessionEvent): void { let nextHeader = state.header @@ -214,8 +214,8 @@ export class TokenMeter extends Service { break } - const surface = isSurfaceEvent(event) - ? foldSurfaceTokens(state.surface, event) + const plan = isSurfaceEvent(event) + ? planSurfaceTokens(state.surface, event) : undefined if (event.type === 'assistant/message') { @@ -228,7 +228,7 @@ export class TokenMeter extends Service { // assistant/message is surface-mandatory at every append/seed boundary. // oxlint-disable-next-line typescript/no-non-null-assertion - const eventTokens = surface!.tokens + const eventTokens = plan!.tokens if (event.data.usage !== undefined && nextHeader !== undefined) { const providerAssistantTokens = this._estimateProviderAssistant( session, @@ -262,9 +262,9 @@ export class TokenMeter extends Service { state.header = nextHeader state.stepStart = nextStepStart - if (surface !== undefined) { - state.surface = surface.nodes - state.surfaceTokens += surface.deltaTokens + if (plan !== undefined) { + commitSurfaceTokens(state.surface, plan) + state.surfaceTokens += plan.deltaTokens } state.anchor = nextAnchor } diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index 2848025b19..eb3fdd0451 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -1,12 +1,15 @@ /** * The measurement service's positional surface fold: the per-node priced * surface `measure()` serves and compaction plans against. The projection - * units deliberately do NOT share this fold — their state must stay O(1) - * for the persisted checkpoint, so they ride `surface-projection.ts`'s - * shadow-price protocol instead. Fully metered logs stay in agreement by - * construction: both price through `estimate.ts`, and every logged shadow - * price is derived from THIS fold's nodes by the replace producer. A - * projection replacement without a claim deliberately folds with zero delta. + * units do NOT share this fold — their state must stay O(1) for the + * persisted checkpoint, so they ride `surface-projection.ts`'s shadow-price + * protocol; the two agree because both price through `estimate.ts` and every + * logged shadow price derives from this fold's nodes. + * + * The fold is a plan/commit pair: {@link planSurfaceTokens} runs every + * fallible step read-only and {@link commitSurfaceTokens} mutates in place, + * so a throw leaves the caller's state untouched and the same malformed + * event fails identically on every retry. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ @@ -16,50 +19,61 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import type { TokenSurfaceNode } from './types.ts' import { estimateMessage } from './estimate.ts' -/** One surface event's placement and cost against the surface preceding it. */ -export interface SurfaceTokenFold { +/** One validated surface transition that has not mutated the priced surface yet. */ +export interface SurfaceTokenPlan { /** Heuristic price of the event's own message; 0 when it derives none. */ readonly tokens: number - /** The surface after the event, detached from the input. */ - readonly nodes: TokenSurfaceNode[] /** Signed change in the surface total: `tokens` minus anything shadowed. */ readonly deltaTokens: number + /** The priced node the commit inserts for this event. */ + readonly node: TokenSurfaceNode + /** Commit position: `append`, or the inclusive replaced index range. */ + readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } /** - * Fold one surface event onto a priced surface. - * - * Total and allocation-fresh: the caller assigns the result rather than - * mutating in place, so a throw here leaves the caller's state untouched and - * the same malformed event fails identically on every retry. + * Validate and price one surface event without mutating the surface. * @param nodes - the priced surface preceding this event, in model-visible order. * @param event - the surface event to place. - * @returns the event's price, the next surface, and the signed total delta. + * @returns the plan for {@link commitSurfaceTokens}. * @throws when a replacement names a range absent from `nodes` — committed * logs are surface-validated at append time, so an unresolvable range is log * corruption and must fail loud rather than skip the event. */ -export function foldSurfaceTokens( +export function planSurfaceTokens( nodes: readonly TokenSurfaceNode[], event: SurfaceEvent, -): SurfaceTokenFold { +): SurfaceTokenPlan { const message = deriveEventMessage(event) const tokens = message === null ? 0 : estimateMessage(message) + const node = { seq: event.seq, tokens } const op = event.surfaceOp if (op === 'append') { - return { tokens, nodes: [...nodes, { seq: event.seq, tokens }], deltaTokens: tokens } + return { tokens, deltaTokens: tokens, node, target: 'append' } } - const startIdx = nodes.findIndex(node => node.seq === op.start) - const endIdx = nodes.findIndex(node => node.seq === op.end) + const startIdx = nodes.findIndex(candidate => candidate.seq === op.start) + const endIdx = nodes.findIndex(candidate => candidate.seq === op.end) if (startIdx === -1 || endIdx === -1 || startIdx > endIdx) { throw new Error( `token surface: replace at seq ${event.seq} has invalid current range ${op.start}-${op.end}`, ) } - const removed = nodes - .slice(startIdx, endIdx + 1) - .reduce((total, node) => total + node.tokens, 0) - const next = [...nodes] - next.splice(startIdx, endIdx - startIdx + 1, { seq: event.seq, tokens }) - return { tokens, nodes: next, deltaTokens: tokens - removed } + let removed = 0 + // oxlint-disable-next-line typescript/no-non-null-assertion -- startIdx..endIdx are validated indices + for (let index = startIdx; index <= endIdx; index += 1) removed += nodes[index]!.tokens + return { tokens, deltaTokens: tokens - removed, node, target: { startIdx, endIdx } } +} + +/** + * Apply one validated plan to the priced surface in place; infallible, so it + * cannot leave a half-applied surface behind. + * @param nodes - the exact priced surface the plan was built against. + * @param plan - the transition returned by {@link planSurfaceTokens}. + */ +export function commitSurfaceTokens(nodes: TokenSurfaceNode[], plan: SurfaceTokenPlan): void { + if (plan.target === 'append') { + nodes.push(plan.node) + return + } + nodes.splice(plan.target.startIdx, plan.target.endIdx - plan.target.startIdx + 1, plan.node) } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 074f18fb76..5cd10fe4ce 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -467,6 +467,33 @@ describe('malformed replay and listener lifecycle', () => { expectRepeatedFailure(meter(), session, /no matching step\/start/) }) + it('leaves the priced surface uncommitted when a later validation step rejects the event', () => { + // A valid append plan whose anchor validation throws: only commit + // ordering keeps the surface from double-counting across retries. + const session = Session.create(SessionId('bad-step-surface')) + appendHeader(session, header('deepseek-v4-flash')) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'planned but never committed' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'deepseek-v4-flash' }, + }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + const service = meter() + const states = (service as unknown as { + states: WeakMap + }).states + expectRepeatedFailure(service, session, /no matching step\/start/) + const state = states.get(session) + expect(state?.surface).toEqual([]) + expect(state?.surfaceTokens).toBe(0) + }) + it('clears completed step boundaries and rejects overlapping or late step events', () => { const overlapping = Session.create(SessionId('overlapping-step')) overlapping.append('step/start', { turn: 1, step: 1 }) From 4db19c352e22153b6808fff75079849e28a4abf7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 24 Aug 2026 16:22:20 +0800 Subject: [PATCH 16/37] docs(token-meter): distinguish projection and measurement folds --- packages/llm/token-meter/README.i18n.yaml | 4 ++-- packages/llm/token-meter/README.md | 4 ++-- packages/llm/token-meter/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index 98b96e634d..4f2d14cdc6 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/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/llm/token-meter/README.md -README.md: a2deab11a31285ba598b8864d3a734ecf7c56620 -README.zh.md: d14cded74691f88db7267ea470f536db85a39218 +README.md: 9cc56c0ac5e445f2de63cb71aa0b0e9354ae8492 +README.zh.md: eb2cfa9b1130c1ff227a284e84ad9afc979cee60 diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index a2deab11a3..9cc56c0ac5 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -29,9 +29,9 @@ When the composition provides `ctx.sessionProjections`, token-meter registers th `contextPressure` carries optional `pressureTokens` — the newest provider-reported prompt size, summing uncached input plus cache reads and writes — optional `projectedTokens`, and optional `contextWindow` from the newest `request/context` record. Both figures stay absent until a provider reports usage; capacity stays absent for a route whose adapter advertises none. Output is excluded, so `pressureTokens` holds still while a turn streams and steps forward when the next request reports its usage. -`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero and folded through the same `surface-fold.ts` the measurement service replays. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. +`projectedTokens` is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken, clamped at zero. Its O(1) fold in `surface-projection.ts` tracks appends and consumes the logged shadow price immediately before a replacement; on fully metered logs it agrees with the measurement service's positional plan/commit fold without retaining per-node prices. Only the delta is estimated, so the figure stays anchored to the provider while reacting the moment content lands — or a compaction shadows a span. That last case is why the field exists: compaction summarizes through a direct `ctx.llm.stream()` call and appends no usage of its own, so `pressureTokens` alone reports the pre-compaction prompt until an entire further turn completes. Occupancy displays read `projectedTokens`. -`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays `surface-fold.ts` — the same positional fold `measure()` runs — so it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals `measure().surfaceTokens` at every event boundary and compaction shrinks it the way it shrinks the next request. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. All three units use the standard projection baseline, live frame, higher-seq-wins store, and JSON checkpoint paths. Unloading token-meter removes all three keys. A composition without the projection seam keeps the measurement service's existing behavior. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index d14cded746..eb2cfa9b11 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -29,9 +29,9 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成 `contextPressure` 携带可选的 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和)、可选的 `projectedTokens`,以及来自最新一条 `request/context` 记录的可选 `contextWindow`。提供方报告用量前两个数字都保持缺失;路由适配器未公布容量时容量也保持缺失。输出不计入其中,因此轮次流式输出期间 `pressureTokens` 保持不动,等到下一个请求报告用量时才前进。 -`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,下界钳制为零,折叠走的是测量服务重放的同一份 `surface-fold.ts`。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。 +`projectedTokens` 是「下一个请求的提示词要花多少」:在该样本之上,加上自取样以来表层增减部分的启发式重新计价,并将下界钳制为零。它在 `surface-projection.ts` 中的 O(1) 折叠会跟踪追加,并消费紧邻替换之前记录的影子价;在完整计量的日志上,它无需保留逐节点价格也能与测量服务的带位置 plan/commit 折叠一致。只有增量部分是估算的,因此这个数字既锚定在提供方读数上,又能在内容落地——或压缩遮蔽一段区间——的瞬间做出反应。最后这种情况正是该字段存在的理由:压缩通过直连的 `ctx.llm.stream()` 调用生成摘要,自身不追加任何用量,所以仅凭 `pressureTokens` 会一直报告压缩前的提示词规模,直到再完成一整个轮次为止。占用率展示读取 `projectedTokens`。 -`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放 `surface-fold.ts`——也就是 `measure()` 运行的同一个带位置 fold——因此它在每个事件边界上都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().surfaceTokens`,压缩会像缩小下一个请求那样缩小它。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值:它们加起来不等于 `projectedTokens`——后者的提供方锚点所体现的恰好是这些明细行仍然带着的误差(按「4 字符 ≈ 1 token」计价,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 三个单元都使用标准的投影基线、实时帧、seq 高者胜值仓和 JSON 检查点路径。卸载 token-meter 会移除这三个键。不带投影 seam 的组合会保留测量服务的既有行为。 From c27de594fd518951e1d4be16f3f0032d3220f8bc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 17:59:02 +0800 Subject: [PATCH 17/37] =?UTF-8?q?feat(web):=20=E5=9C=A8=20Trajectory=20?= =?UTF-8?q?=E4=B8=AD=E5=B1=95=E7=A4=BA=E5=9B=BE=E7=89=87=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trajectory 现在通过共享的 ui-attachment 画廊渲染会话日志中的持久化图片引用:ui-conversation 拥有按会话的图片 URL 缓存(ctx.uiConversation.imageUrl),ui-trajectory 声明 conversation.trajectory.images 子槽位,纯图片记录行以图片数量标注,内联 imageSrc 嗅探作为死代码删除。 Closes #2986 --- ...-24-trajectory-image-attachments.i18n.yaml | 6 ++ ...2026-08-24-trajectory-image-attachments.md | 33 ++++++ ...6-08-24-trajectory-image-attachments.zh.md | 33 ++++++ .../trajectory-image-display.snapshot.ts | 93 ++++++++++++++++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- packages/client/ui-attachment/package.json | 7 +- .../client/ui-attachment/src/client/index.ts | 5 + .../ui-attachment/tests/plugin.client.spec.ts | 8 +- packages/client/ui-attachment/tsconfig.json | 3 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- packages/client/ui-chat/package.json | 2 - packages/client/ui-chat/src/client/apply.ts | 4 +- .../ui-chat/src/client/chat/ChatView.tsx | 6 +- .../ui-chat/src/client/contract/slots.ts | 13 +-- packages/client/ui-chat/src/client/index.ts | 3 +- .../tests/image-labels.client.spec.tsx | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 14 +++ .../src/client/conversation/assembly.ts | 15 +++ .../client/conversation}/historical-images.ts | 23 ++-- .../ui-conversation/src/client/index.ts | 2 +- .../tests/historical-images.client.spec.ts | 6 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 6 +- .../src/client/TrajectoryTable.module.css | 29 ----- .../src/client/TrajectoryTable.tsx | 97 +++++++++-------- .../src/client/TrajectoryView.tsx | 20 +++- .../client/ui-trajectory/src/client/index.ts | 4 + .../client/ui-trajectory/src/client/layout.ts | 72 ++++--------- .../ui-trajectory/src/client/locales.ts | 4 +- .../src/client/trajectory-contract.ts | 14 ++- .../src/client/trajectory-record.ts | 4 +- .../src/client/trajectory-search-index.ts | 2 +- .../tests/layout.client.spec.tsx | 87 +++++++++++++++ .../ui-trajectory/tests/table.client.spec.tsx | 102 +++++++++++++++++- .../ui-trajectory/tests/views.client.spec.tsx | 4 + packages/client/ui-trajectory/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 90 +++++++++++----- pnpm-lock.yaml | 9 +- 47 files changed, 631 insertions(+), 226 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md create mode 100644 apps/web/tests/trajectory-image-display.snapshot.ts rename packages/client/{ui-chat/src/client => ui-conversation/src/client/conversation}/historical-images.ts (80%) rename packages/client/{ui-chat => ui-conversation}/tests/historical-images.client.spec.ts (79%) diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml new file mode 100644 index 0000000000..b82bec2f49 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.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-24-trajectory-image-attachments.md +2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13 +2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md new file mode 100644 index 0000000000..4b79ecbb11 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory durable image attachments + +Status: implemented + +English | [中文](2026-08-24-trajectory-image-attachments.zh.md) + +## Problem + +Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `ImageAttachmentRef` before its event is appended. Users could not confirm from the execution ledger which image the model saw ([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)), while Chat already displayed the same attachments. + +## Decision + +- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. +- The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. +- `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths. +- A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump. +- Neither the storage nor the BFF changes: `session.attachment` already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once. + +## Alternatives considered + +**Keep Trajectory's own `` rendering and feed it resolved URLs.** This duplicates the loading placeholder, retry control, and lightbox that `ui-attachment` already owns, and contradicts [slot-based attachment ownership](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md), which rejected cross-plugin component imports. + +**Lift the `conversation.message.images` declaration to a shared parent so both views render one key.** `renderSlot` is typed to the declaring entry's own children table, so a sibling `conversation.view` entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently. + +**Keep the inline `imageSrc` sniffing beside the durable path.** All producers (host prompt admission, `read_image`, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude. + +**A Trajectory-owned image cache.** A second cache per view issues duplicate `session.attachment` RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit. + +## Consequences + +- Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it. +- `TrajectoryTable` threads a required `renderImages` prop through its detail components; `ui-trajectory` gains a type-only dependency on `dsh-attachment`, and `ui-attachment` gains a type-only dependency on `ui-trajectory` for the new SlotMap row. +- The keyless assembled snapshot `apps/web/tests/trajectory-image-display.snapshot.ts` pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md new file mode 100644 index 0000000000..f7695ab322 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory 持久化图片附件 + +Status: implemented + +[English](2026-08-24-trajectory-image-attachments.md) | 中文 + +## Problem + +Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: ImageAttachmentRef }` 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(`url`、`image_url`、base64 `data`)的 `imageSrc` 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 `ImageAttachmentRef`。用户无法从执行记录确认模型看到了哪张图([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)),而 Chat 已经能展示同样的附件。 + +## Decision + +- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。 +- 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。 +- `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。 +- 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。 +- 存储与 BFF 均不改动:`session.attachment` 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。 + +## Alternatives considered + +**保留 Trajectory 自己的 `` 渲染并喂给它解析好的 URL。** 这会重复 `ui-attachment` 已拥有的加载占位、重试控件和灯箱,并与[基于 slot 的附件所有权](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)相抵触,该决定已拒绝跨插件直接 import 组件。 + +**把 `conversation.message.images` 的声明上提到共享父级,让两个视图渲染同一个键。** `renderSlot` 的类型限定在声明入口自己的 children 表内,同级的 `conversation.view` 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。 + +**在持久化路径之外保留内联 `imageSrc` 嗅探。** 所有生产方(宿主 prompt admission、`read_image`、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。 + +**Trajectory 自有的图片缓存。** 每个视图一份缓存会对同一会话附件发出重复的 `session.attachment` RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。 + +## Consequences + +- 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。 +- `TrajectoryTable` 需要把必填的 `renderImages` prop 逐层传入详情组件;`ui-trajectory` 新增对 `dsh-attachment` 的仅类型依赖,`ui-attachment` 为新的 SlotMap 行新增对 `ui-trajectory` 的仅类型依赖。 +- keyless 组装快照 `apps/web/tests/trajectory-image-display.snapshot.ts` 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。 diff --git a/apps/web/tests/trajectory-image-display.snapshot.ts b/apps/web/tests/trajectory-image-display.snapshot.ts new file mode 100644 index 0000000000..90659cf2b5 --- /dev/null +++ b/apps/web/tests/trajectory-image-display.snapshot.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session whose turn 73 carries an image in BOTH a +// user message and an assistant message, and pins the Trajectory surfaces: +// selecting the ledger record renders the shared ui-attachment gallery from +// the durable session-log reference, and the browser URL is the SAME object +// URL Chat resolved — one sessions.attachment read per session attachment. +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open the fixture history session and wait for the Chat gallery to load. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) +} + +/** Scroll the virtual ledger until the row whose text contains `needle` mounts. */ +async function scrollRowIntoWindow(needle: string): Promise { + await waitFor(() => { + if (document.querySelectorAll('tr[data-trajectory-row-key]').length === 0) { + throw new Error('trajectory rows not mounted') + } + }, { timeout: 10_000 }) + const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement + if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing') + for (let top = 0; top <= 40_000; top += 1_000) { + pane.scrollTop = top + fireEvent.scroll(pane) + // Let the virtualizer publish the new window before probing. + await new Promise(resolve => setTimeout(resolve, 25)) + const hit = [...document.querySelectorAll('tr[data-trajectory-row-key]')] + .find(row => row.textContent?.includes(needle)) + if (hit !== undefined) return hit + } + throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`) +} + +it('renders durable record images in the Trajectory details panel from the shared cache', async () => { + // The virtual ledger needs a measurable viewport; jsdom reports zero + // heights, so pin one and neutralize the imperative tail scroll. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: () => {}, + }) + mountAssembledApp() + await openFixtureSession() + const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src') + if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing') + + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + const userRow = await scrollRowIntoWindow('历史用户图片') + fireEvent.click(userRow) + + // Selecting the record opens the details panel; the ui-attachment gallery + // resolves the durable reference through the SAME per-session cache Chat + // used, so the object URL is identical — no second attachment read. + const panel = await screen.findByRole('tabpanel') + await waitFor(() => { + expect(within(panel).getAllByRole('img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) + expect(within(panel).getAllByRole('img').map(img => ({ + alt: img.getAttribute('alt'), + scheme: img.getAttribute('src')?.split(':')[0], + sharedWithChat: img.getAttribute('src') === chatSrc, + }))).toMatchInlineSnapshot(` + [ + { + "alt": "fixture-image.png", + "scheme": "blob", + "sharedWithChat": true, + }, + ] + `) +}) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 4bbc3c3dcd..40aab703eb 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 925187da23e9acec9a69959bfc29ce7ec62f0096 -README.zh.md: 2872dac19e36143923ba39e4b3efbd6e8bb27cac +README.md: ee9b604d0227b5ae52f833f97f80429e4ab93bea +README.zh.md: 8d8cd43c32587f258de3e6a26fa7e0d0127dbf57 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 925187da23..ee9b604d02 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. +Dynamic attachment presentation plugin for the conversation UI. It waits for the `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, the history image gallery serving both the Chat transcript and the Trajectory inspector, and the original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. ## Attachment rail diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 2872dac19e..8d8cd43c32 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 +对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images` 声明,随后注册输入框草稿图片栏、文档拖放目标、同时服务 Chat 会话记录与 Trajectory 检查器的历史图片画廊,以及原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 ## 附件栏 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 71ba3edf76..d5a10b446b 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", - "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", + "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -34,7 +34,8 @@ "inject": [ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-trajectory" ], "platform": "web" } @@ -54,6 +55,7 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -72,6 +74,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^" } diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index bcdbfb7adf..8fe94f64ee 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' @@ -19,4 +20,8 @@ export function apply(ctx: ClientContext): void { name: 'conversation.message.images', locale: 'conversation', }, MessageImages)) + ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ + name: 'conversation.trajectory.images', + locale: 'conversation', + }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 9ca742377f..21d84953de 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -14,6 +14,7 @@ async function bench() { children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -26,7 +27,7 @@ describe('attachment plugin', () => { expect(() => { applyHost() }).not.toThrow() }) - it('registers both entries and removes them with the plugin fiber', async () => { + it('registers all entries and removes them with the plugin fiber', async () => { const { ctx, fiber } = await bench() expect(inject).toEqual(['slots']) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ @@ -37,10 +38,15 @@ describe('attachment plugin', () => { locale: 'conversation', component: MessageImages, }]) + expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ + locale: 'conversation', + component: MessageImages, + }]) await fiber.dispose() expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) + expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 0cd20ee366..b2783b20e9 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-trajectory" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4abebdd3f3..4859153e08 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3 -README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f +README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288 +README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 56bb20ab7b..5253cb95b0 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 40ee1ee710..8cd2d0d581 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。 ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 186afe1f55..b29c675a08 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -72,7 +72,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { @@ -101,7 +100,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 1e0730d740..3243c6c642 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -23,7 +23,6 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { DetailsPanel } from './details/DetailsPanel.tsx' -import { HistoricalImageCache } from './historical-images.ts' import { en, NS, zh } from './locale.ts' import { createChatStore } from './stores.ts' @@ -74,7 +73,6 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(NS) const chatStore = createChatStore() const chatScrollPositions = new Map() - const images = new HistoricalImageCache(ctx) ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ @@ -102,7 +100,7 @@ export function apply(ctx: Context): void { return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void session.loadOlder() }, - loadImage: attachment => images.resolve(sessionId, attachment), + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), chatScroll: { save: (position) => { if (position === null) chatScrollPositions.delete(sessionId) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 88c2f66f68..a647ad5c18 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,9 +2,11 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationTimelineSnapshot, RenderMessageImages, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5009781fd1..cff42849eb 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,9 +1,8 @@ /** Chat-owned Slot declarations and composed component props. */ -import type { ReactNode } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, TurnLocation, + ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -19,16 +18,6 @@ import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook -/** Historical image group handed to the optional attachment presentation plugin. */ -export interface MessageImagesOwnerProps { - images: readonly { readonly attachment: ImageAttachmentRef }[] - loadImage: (attachment: ImageAttachmentRef) => Promise - align: 'start' | 'end' -} - -/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode - /** Owner currency of the completed-Turn extension chain. */ export interface TurnTailOwnerProps { turn: TurnLocation diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index a847b039fd..c04c2c588e 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -28,8 +28,7 @@ export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, - TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' export type { ChatKey } from './locale.ts' export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index b260850bc3..bb5d339f0a 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -5,8 +5,8 @@ import { cleanup, render } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import type { RenderMessageImages } from '../src/client/contract/slots.ts' import { zh } from '../src/client/locale.ts' afterEach(cleanup) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a6102ddac0..20b50fa39b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 4c9665b680fe1922770403d88a04dffc755481ad -README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635 +README.md: 14d2665abea44f395e758deaf527bfe767e0fff6 +README.zh.md: ae118eb2f07db9ee7ee464ee9b467721c20254e7 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4c9665b680..14d2665abe 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. ## Conversation assembly diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8bf3db2cb1..ae118eb2f0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 ## Conversation 组装 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 57c686383b..a7865d5906 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { @@ -43,6 +44,19 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } +/** Durable image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + /** Durable image references in source order. */ + images: readonly { readonly attachment: ImageAttachmentRef }[] + /** Session-authorized image URL loader. */ + loadImage: (attachment: ImageAttachmentRef) => Promise + /** Horizontal placement inside the owning record. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + /** Selector hook over the current Session's assembled Conversation. */ export type UseConversation = SnapshotSelectorHook /** Selector hook over the registered Conversation View roster. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 58ea1bf79d..26801e161b 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -1,5 +1,6 @@ /** Per-Session target-neutral Conversation assembly. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions, SessionBinding, SessionEventSource, SessionEventWindow, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -15,6 +16,7 @@ import type { import type { ConversationSnapshot } from '../contract/snapshot.ts' import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' +import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ @@ -144,6 +146,7 @@ export class UiConversation extends Service { /** Registry of target View definitions. */ readonly views: ConversationViewRegistry private readonly bindings = new Map() + private readonly images: HistoricalImageCache /** * @param ctx - owning Client context. @@ -153,6 +156,7 @@ export class UiConversation extends Service { super(ctx, 'uiConversation') this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) + this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values()) record.binding.rebuild() } @@ -202,6 +206,17 @@ export class UiConversation extends Service { return binding } + /** + * Resolve one session-authorized durable image URL, cached per Session so + * every Conversation target shares one read and one browser URL. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference from a session event. + * @returns browser URL valid until the Session binding is released. + */ + imageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + return this.images.resolve(sessionId, attachment) + } + private drop(record: BindingRecord, releaseScope: boolean): void { if (this.bindings.get(record.source.sessionId) !== record) return this.bindings.delete(record.source.sessionId) diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts similarity index 80% rename from packages/client/ui-chat/src/client/historical-images.ts rename to packages/client/ui-conversation/src/client/conversation/historical-images.ts index 1e53e78bb8..602b104c1d 100644 --- a/packages/client/ui-chat/src/client/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -1,4 +1,4 @@ -/** Session-scoped historical image URL cache owned by the Chat plugin. */ +/** Session-scoped durable image URL cache shared by Conversation targets. */ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' @@ -11,9 +11,8 @@ interface ImageUrlEntry { readonly pending: Promise } -/** Resolve durable Chat images and release their browser URLs with Session scope. */ +/** Resolve durable Conversation images and release their browser URLs with Session scope. */ export class HistoricalImageCache { - private readonly sessions: ISessions private readonly entries = new Map() private readonly generations = new Map() private readonly scopeDisposers = new Map void>() @@ -21,11 +20,11 @@ export class HistoricalImageCache { private disposed = false /** - * @param ctx - Owning ui-chat fiber. + * @param ctx - Owning ui-conversation fiber. + * @param sessions - Session Controller object layer. */ - constructor(ctx: Context) { - this.sessions = ctx.sessions - ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache') + constructor(ctx: Context, private readonly sessions: ISessions) { + ctx.effect(() => () => { this.dispose() }, 'ui-conversation historical image cache') } /** @@ -35,22 +34,22 @@ export class HistoricalImageCache { * @returns browser URL valid until the Session binding is released. */ resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { - if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed')) + if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed')) const key = `${sessionId}:${attachment.attachmentId}` const cached = this.entries.get(key) if (cached !== undefined) return cached.pending const binding = this.sessions.binding(sessionId) if (binding === undefined) { - return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`)) + return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`)) } this.bindScope(sessionId, binding.ctx) const generation = this.generations.get(sessionId) ?? 0 const pending = binding.session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed') + if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') if ((this.generations.get(sessionId) ?? 0) !== generation) { - throw new Error('ui-chat image scope was released before loading completed') + throw new Error('ui-conversation image scope was released before loading completed') } if (typeof URL.createObjectURL !== 'function') { return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` @@ -73,7 +72,7 @@ export class HistoricalImageCache { const dispose = scope.effect(() => () => { this.scopeDisposers.delete(sessionId) this.release(sessionId) - }, 'ui-chat historical image scope') + }, 'ui-conversation historical image scope') this.scopeDisposers.set(sessionId, () => { void dispose() }) } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0d6874f5a3..966135c043 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -51,7 +51,7 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - UseConversation, UseConversationViews, + MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-chat/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts similarity index 79% rename from packages/client/ui-chat/tests/historical-images.client.spec.ts rename to packages/client/ui-conversation/tests/historical-images.client.spec.ts index 1642923a31..71aa3d6018 100644 --- a/packages/client/ui-chat/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { HistoricalImageCache } from '../src/client/historical-images.ts' +import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts' describe('HistoricalImageCache', () => { it('invalidates a pending image load when its Session binding is released', async () => { @@ -13,7 +13,7 @@ describe('HistoricalImageCache', () => { id: 's1', session: { readAttachment: () => read.promise }, }) - const cache = new HistoricalImageCache(runtime.ctx) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) const attachment = { attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, } as const @@ -22,7 +22,7 @@ describe('HistoricalImageCache', () => { await runtime.sessions.remove(sessionId) read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) - await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed') + await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) }) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 47b35ae17d..96f48c6888 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 97badd562bbf132c9766c763ff604306d1a6c08d -README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00 +README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943 +README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 97badd562b..c623105b9b 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Durable image attachments in user input, assistant output, and tool results render through the `conversation.trajectory.images` gallery slot: a record without text labels its row with the image count, the inspector shows each image with the shared loading, retry, and lightbox behavior, and image URLs come from the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5a17ee8110..007f4451be 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。用户输入、助手输出和工具结果中的持久化图片附件通过 `conversation.trajectory.images` 画廊 slot 渲染:没有文本的记录行以图片数量标注,检查器内展示每张图片并复用共享的加载、重试与灯箱行为,图片 URL 来自 Conversation 持有的按会话缓存,因此 Chat 与 Trajectory 对同一附件共享一次经会话授权的读取。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 5f4fa4981d..054dc9ddf9 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -62,7 +62,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -85,7 +86,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 71d0b3031b..4602f925af 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1545,35 +1545,6 @@ white-space: pre-wrap; } -.panelImageLink { - display: block; - width: auto; - max-width: 100%; - overflow: hidden; - border-radius: 4px; - cursor: zoom-in; -} - -.panelImageLinkPreview { - max-height: 140px; -} - -.panelImage { - display: block; - width: auto; - max-width: 100%; - height: auto; - max-height: 320px; - margin: 0; - border-radius: inherit; - background: var(--dsw-alias-bg-base); - object-fit: contain; -} - -.panelImageLinkPreview .panelImage { - max-height: 140px; -} - .messageImages { display: flex; flex-direction: column; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b3f5f3415e..703675795e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,8 +14,9 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantRequestConfig, ConversationPromptSnapshot, + AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, @@ -377,6 +378,8 @@ function AssistantTimingPanel({ export interface TrajectoryTableProps { /** Trajectory locale seat. */ t: TrajectoryTranslate + /** Slot-backed durable image renderer shared with the Chat gallery. */ + renderImages: RenderMessageImages /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -1121,10 +1124,12 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, + renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { return ( @@ -1155,8 +1160,8 @@ function SourceBlocks({ )} - {block.imageSrc !== undefined - ? + {block.attachment !== undefined + ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) :
{block.content}
} ))} @@ -1164,47 +1169,27 @@ function SourceBlocks({ ) } -function PanelImage({ - block, - preview = false, - t, -}: { - block: TrajectorySourceBlock - preview?: boolean - t: TrajectoryTranslate -}) { - if (block.imageSrc === undefined) return null - return ( - - {block.imageAlt - - ) +function recordImages( + blocks: readonly TrajectorySourceBlock[] | undefined, +): { readonly attachment: ImageAttachmentRef }[] { + return (blocks ?? []).flatMap(block => + block.attachment !== undefined ? [{ attachment: block.attachment }] : []) } function MessageImages({ blocks, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { - const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + const images = recordImages(blocks) if (images.length === 0) return null return (
- {images.map((block, index) => )} + {renderImages({ images, align: 'start' })}
) } @@ -1404,12 +1389,12 @@ function ToolOutputBlocks({ blocks, error, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { return (
value !== undefined).join(' ')} > {blocks.map((block, index) => ( - block.imageSrc !== undefined - ? + block.attachment !== undefined + ? ( +
+ {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })} +
+ ) : block.content !== '' ?
{block.content}
: null @@ -1436,6 +1425,7 @@ function MarkdownRecordContent({ thinkingExpanded, onThinkingExpandedChange, onOpenCall, + renderImages, t, }: { record: TableRecord @@ -1444,10 +1434,18 @@ function MarkdownRecordContent({ thinkingExpanded: boolean onThinkingExpandedChange: (expanded: boolean) => void onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { - return + return ( + + ) } if (record.cell.thinkingDetail) { if (!rendered) { @@ -1502,13 +1500,13 @@ function MarkdownRecordContent({
) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true if (!source && !hasImages && !hasToolCalls) { @@ -1531,7 +1529,7 @@ function MarkdownRecordContent({ t={t} /> )} - + ) } @@ -1590,11 +1588,13 @@ function RecordPayload({ record, direction, preview = false, + renderImages, t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean + renderImages: RenderMessageImages t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail @@ -1624,14 +1624,14 @@ function RecordPayload({ if ( direction === 'output' && record.cell.outputBlocks?.some(block => - block.imageSrc !== undefined || block.content !== '') === true + block.attachment !== undefined || block.content !== '') === true ) { return ( ) } @@ -1791,6 +1791,7 @@ function OverviewSection({ */ export function TrajectoryTable({ t, + renderImages, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -2990,6 +2991,7 @@ export function TrajectoryTable({ > { activateTab('rendered') }}> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> @@ -3151,6 +3154,7 @@ export function TrajectoryTable({ {!promptSelected && selected !== undefined && activeTab === 'rendered' && ( )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1e36f4431b..8b982b1649 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,10 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantBlock, AssistantMessageNode, ConvViewProps, + AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TrajectoryTable, @@ -69,6 +70,7 @@ export interface TrajectoryViewInjected { duration: SnapshotStore } loadOlder: () => Promise + loadImage: (attachment: ImageAttachmentRef) => Promise setActualDuration: (actualDuration: boolean) => void } @@ -117,10 +119,17 @@ function addUsage( } export function TrajectoryView({ - useSession, useTrajectory, useDuration, loadOlder, setActualDuration, - viewRequest, completeViewRequest, t, -}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { + useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, + viewRequest, completeViewRequest, renderSlot, t, +}: ConvViewProps + & PropsRenderSlots<'conversation.trajectory.images'> + & InjectFace + & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) + const renderImages = useCallback( + owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) const [timelineSelection, setTimelineSelection] = useState(null) @@ -481,6 +490,7 @@ export function TrajectoryView({
t('view.trajectory'), + children: { + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + }, inject: (sessionId: SessionId): TrajectoryViewInjected => { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) { @@ -92,6 +95,7 @@ export function apply(ctx: Context): void { await session.loadOlder() return trajectory.getSnapshot() !== before }, + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), setActualDuration: (value) => { duration.set(value) }, } }, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 88aa5c0cbf..0283f4fde3 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,6 +12,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -108,7 +109,7 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number { : entry.seq } -function inputCellDetail(node: InputNode): Pick< +function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< TrajectoryCellProps, | 'text' | 'previewMarkdown' @@ -120,8 +121,11 @@ function inputCellDetail(node: InputNode): Pick< | 'startedAt' > { const previewMarkdown = previewContent(node.content) + const images = imageBlockCount(node.content) return { - text: '', + text: previewMarkdown === undefined && images > 0 + ? t('layout.imageOnly', { count: images }) + : '', ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -368,7 +372,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user', - ...inputCellDetail(node), + ...inputCellDetail(node, t), opensTurn: true, }, }) @@ -387,7 +391,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user' as const, - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, } if (placement.step === undefined) pushMessage(placement.turn, laid) @@ -415,7 +419,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'context', - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -788,6 +792,8 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } + const images = imageBlockCount(blocks.map(block => ({ type: block.kind }))) + if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -808,12 +814,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } - // Attachment refs carry no fetchable bytes, so the record shows the - // durable metadata instead of an inline preview. - case 'image': return { - type: 'image', - content: stringifySourceValue(block.attachment), - } + case 'image': return { type: 'image', content: '', attachment: block.attachment } case 'other': return sourceBlock(block.block) } } @@ -827,47 +828,16 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - const imageSrc = sourceImage(block) - const imageAlt = typeof block.alt === 'string' ? block.alt : undefined - return { - type, - content: imageSrc === undefined ? stringifySourceValue(value) : '', - ...(imageSrc !== undefined ? { imageSrc } : {}), - ...(imageAlt !== undefined ? { imageAlt } : {}), + if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + // Typed content only reaches here as a core ImageBlock; wire-shaped + // 'other' blocks never define `attachment`. + return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } + return { type, content: stringifySourceValue(value) } } -function sourceImage(block: Record): string | undefined { - if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined - for (const candidate of [block.url, block.image_url]) { - if (typeof candidate === 'string') return safeImageSource(candidate) - } - if (typeof block.data === 'string') { - const mediaType = [block.mimeType, block.mediaType, block.media_type] - .find((candidate): candidate is string => typeof candidate === 'string') - ?? 'image/png' - return safeImageSource( - block.data.startsWith('data:') - ? block.data - : `data:${mediaType};base64,${block.data}`, - ) - } - if (typeof block.source !== 'object' || block.source === null) return undefined - const source = block.source as Record - if (typeof source.url === 'string') return safeImageSource(source.url) - if (typeof source.data !== 'string') return undefined - const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' - return safeImageSource(`data:${mediaType};base64,${source.data}`) -} - -function safeImageSource(value: string): string | undefined { - if (value.startsWith('data:image/') || value.startsWith('blob:')) return value - try { - const protocol = new URL(value).protocol - return protocol === 'http:' || protocol === 'https:' ? value : undefined - } catch { - return undefined - } +function imageBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'image').length } function stringifySourceValue(value: unknown): string { @@ -1087,6 +1057,8 @@ function summarizeResult( return { result: '', resultPreviewMarkdown: block.text } } } + const images = imageBlockCount(node.content) + if (images > 0) return { result: t('layout.imageOnly', { count: images }) } return { result: t('record.noOutput') } } @@ -1112,6 +1084,8 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .map(block => block.type === 'text' ? block.text : '') .join('\n') if (text !== '') return text + const images = imageBlockCount(node.content) + if (images > 0) return t('layout.imageOnly', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 70707741a3..ddc5cea7c7 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -121,7 +121,6 @@ export const zh = { 'block.openSummary': '打开第 {index} 个块的工具调用概述', 'block.openSummaryTitle': '打开工具调用概述', 'block.label': '块 #{index} {type}', - 'block.openImage': '打开图片', 'history.loadingTrajectory': '正在加载轨迹…', 'history.loadingEarlier': '正在加载更早的历史…', 'history.loadingEarlierAria': '正在加载更早的历史…', @@ -174,6 +173,7 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', + 'layout.imageOnly': '图片 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -313,7 +313,6 @@ export const en: Record = { 'block.openSummary': 'Open Block #{index} tool call summary', 'block.openSummaryTitle': 'Open tool call summary', 'block.label': 'Block #{index} {type}', - 'block.openImage': 'Open image', 'history.loadingTrajectory': 'Loading trajectory…', 'history.loadingEarlier': 'Loading earlier history…', 'history.loadingEarlierAria': 'Loading earlier history…', @@ -366,6 +365,7 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', + 'layout.imageOnly': 'Images ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index 5a96479bee..37571f2b83 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -1,7 +1,7 @@ import type { AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot, - ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall, - ToolCallBlock, + ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange, + RequestView, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -84,4 +84,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Selector hook over the current Conversation binding's Trajectory target. */ useTrajectory: UseTrajectory } + + interface SlotMap { + /** + * Renderer for one group of durable record images in the Trajectory + * ledger. The owner supplies image references, an authorized loader, and + * alignment. A registration replaces the shipped gallery; without one, + * images are omitted. + */ + 'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 43fa1b24a8..da330f2ae4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,6 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -28,8 +29,7 @@ export interface AssistantMetricDetail { export interface TrajectorySourceBlock { type: string content: string - imageSrc?: string - imageAlt?: string + attachment?: ImageAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts index 93dddb6856..9641fce1f9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -64,7 +64,7 @@ function recordSources( block.content, block.callId ?? '', block.toolName ?? '', - block.imageAlt ?? '', + block.attachment?.name ?? '', ]), searchableJson(cell.messageSource), searchableJson(cell.promptDetail), diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 6df422e064..bd834cbdb0 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -566,3 +566,90 @@ describe('run_code sub-dispatch cells', () => { expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4]) }) }) + +describe('durable image attachments', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } + + it('carries user image refs into sourceBlocks and labels an image-only record', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×2') + expect(user?.previewMarkdown).toBeUndefined() + expect(user?.sourceBlocks).toEqual([ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ]) + }) + + it('keeps the text preview when a user message mixes text and images', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('') + expect(user?.previewMarkdown).toBe('look at this') + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + }) + + it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0, + blocks: [{ kind: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') + expect(message?.text).toBe('Images ×1') + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('carries tool-result image refs into outputBlocks and labels the result', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1', + call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200, + content: [{ type: 'image', attachment }], isError: false, + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') + expect(tool?.result).toBe('Images ×1') + expect(tool?.outputDetail).toBe('Images ×1') + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', url: 'https://example.com/a.png' }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0] + expect(block?.attachment).toBeUndefined() + expect(block?.content).toContain('https://example.com/a.png') + }) +}) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 7e3b4bee2c..3d3955dcb3 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -4,12 +4,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' import { trajectoryRecordId } from '../src/client/trajectory-record.ts' import { t, tZh } from './locale.client.ts' -function TrajectoryTable(props: Omit, 't'>) { +const renderImagesStub: RenderMessageImages = ({ images }) => ( +
+ {images.map((image, index) => ( + + ))} +
+) + +function TrajectoryTable( + props: Omit, 't' | 'renderImages'> + & { renderImages?: RenderMessageImages }, +) { const inferred: Array[number] & { firstIndex: number }> = [] for (const turn of props.turns) { for (const group of turn.groups) { @@ -40,7 +52,14 @@ function TrajectoryTable(props: Omit left.firstIndex - right.firstIndex) .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) - return + return ( + + ) } afterEach(() => { @@ -129,6 +148,7 @@ describe('TrajectoryTable', () => { render( ()} onToggleTurn={() => {}} @@ -937,6 +957,84 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) + it('renders user image attachments through the shared gallery in the details panel', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Message', + cells: [{ + index: 1, + kind: 'user', + text: 'Images ×2', + sourceBlocks: [ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ], + timeSeconds: 0, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /USER/ })) + + const preview = screen.getAllByTestId('record-images') + expect(preview.length).toBeGreaterThan(0) + expect(preview[0]?.getAttribute('data-count')).toBe('2') + + fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) + const rawGalleries = screen.getAllByTestId('record-images') + expect(rawGalleries).toHaveLength(2) + expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + + it('renders a tool-result image through the shared gallery in the Result tab', () => { + const attachment = { + attachmentId: `sha256:${'b'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 640, + name: 'capture.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'read_image {"path":"a.png"}', + outputDetail: 'Images ×1', + outputBlocks: [{ type: 'image', content: '', attachment }], + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( {}, completeViewRequest: () => {}, + // Image seats the outlet would bake: standalone renders omit the gallery. + renderSlot: () => null, + SessionProvider: ({ children }) => <>{children}, + loadImage: () => Promise.reject(new Error('standalone views load no images')), // The locale seat the outlet would inject for the declared namespace. t: tZh, } diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 8bd120c437..0d0f85fe1d 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../attachment/attachment" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..c9f2019f62 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:186', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:174', }, { key: 'conversation.chat.node', @@ -310,7 +310,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:166', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:155', }, { key: 'conversation.chat.turnTail', @@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:180', }, { key: 'conversation.composer', @@ -404,7 +404,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', }, { key: 'conversation.composer.bar', @@ -440,7 +440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110', }, { key: 'conversation.composer.dock', @@ -498,7 +498,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', }, { key: 'conversation.details.tool', @@ -534,7 +534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:203', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:192', }, { key: 'conversation.hero.agentPreset', @@ -562,7 +562,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', }, { key: 'conversation.hero.brand.mark', @@ -590,7 +590,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', }, { key: 'conversation.hero.workspace', @@ -620,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -686,7 +686,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112', }, { key: 'conversation.input.dock', @@ -746,7 +746,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100', }, { key: 'conversation.input.left', @@ -802,7 +802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', }, { key: 'conversation.input.model', @@ -838,7 +838,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', }, { key: 'conversation.input.overlay', @@ -892,7 +892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102', }, { key: 'conversation.input.plan', @@ -928,7 +928,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118', }, { key: 'conversation.input.right', @@ -984,7 +984,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108', }, { key: 'conversation.message.images', @@ -994,7 +994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n loadImage: (attachment: ImageAttachmentRef) => Promise\n align: \'start\' | \'end\'\n}', + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', @@ -1022,7 +1022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:168', }, { key: 'conversation.session', @@ -1056,7 +1056,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68', }, { key: 'conversation.session.header', @@ -1090,7 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', }, { key: 'conversation.session.header.actions', @@ -1146,7 +1146,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', }, { key: 'conversation.session.header.lineage', @@ -1184,7 +1184,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72', }, { key: 'conversation.session.header.utilities', @@ -1239,7 +1239,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + }, + { + key: 'conversation.trajectory.images', + kind: 'single', + scope: 'session', + summary: 'Renderer for one group of durable record images in the Trajectory ledger.', + doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', + registerOptions: [], + ownerProps: [ + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + ], + ownerPropsReferences: [ + 'ImageAttachmentRef', + ], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.view\' (client-ui-trajectory), so it exists while that entry is mounted', + occupants: [ + 'client-ui-attachment MessageImages', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n { name: \'conversation.trajectory.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95', }, { key: 'conversation.view', @@ -1297,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', }, { key: 'details', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9aef169a41..ba335be6bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2035,6 +2035,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../ui-trajectory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2161,9 +2164,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -3579,6 +3579,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From d420292400936ed062b8a5a07764b77583c59adf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 24 Aug 2026 18:58:25 +0800 Subject: [PATCH 18/37] =?UTF-8?q?fix(web):=20=E5=A4=84=E7=90=86=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E5=8F=91=E7=8E=B0=E7=9A=84=E5=9B=BE=E7=89=87=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=BE=B9=E7=95=8C=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片错误结果在 Result 页签保留错误名称与代码 - 空文本块加图片的记录按纯图片标注,不再空行 - sourceBlock 的持久化图片守卫检查 attachmentId 字段 - 移除 ui-chat 对 util/crypto 的过期 tsconfig 引用 - 同步 slots.md 层级图与 2026-08-20 所有权 Note --- ...t-session-conversation-ownership.i18n.yaml | 4 +-- ...0-client-session-conversation-ownership.md | 6 ++-- ...lient-session-conversation-ownership.zh.md | 6 ++-- ...-24-trajectory-image-attachments.i18n.yaml | 4 +-- ...2026-08-24-trajectory-image-attachments.md | 2 +- ...6-08-24-trajectory-image-attachments.zh.md | 2 +- docs/subsystems/slots.i18n.yaml | 4 +-- docs/subsystems/slots.md | 3 +- docs/subsystems/slots.zh.md | 3 +- packages/client/ui-chat/tsconfig.json | 3 -- .../src/client/TrajectoryTable.tsx | 8 +++++ .../client/ui-trajectory/src/client/layout.ts | 18 +++++++--- .../tests/layout.client.spec.tsx | 13 +++++++ .../ui-trajectory/tests/table.client.spec.tsx | 36 +++++++++++++++++++ 14 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index f616ad829c..a1245a8831 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.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-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa -2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 +2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77 +2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 8e5521ff19..12e137209b 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The | `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | | `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | -| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache | | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | | `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | | `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | @@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna ### Chat owner -`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration. It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. -The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. +The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input. ### Trajectory owner diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index a007a42b3d..f0d9861eea 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把 | `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | | `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | -| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache | | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | | `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | | `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | @@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue ### Chat owner -`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。 它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 -历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 +历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。 ### Trajectory owner diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml index b82bec2f49..6b70d9c8f6 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.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-08-24-trajectory-image-attachments.md -2026-08-24-trajectory-image-attachments.md: 4b79ecbb113a0f36b4cf31f9b0c68cb1fc013d13 -2026-08-24-trajectory-image-attachments.zh.md: f7695ab322f80699f1f424d26d179415f0181efc +2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c +2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md index 4b79ecbb11..6f89840a7d 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -10,7 +10,7 @@ Trajectory did not display session images. A durable `{ type: 'image', attachmen ## Decision -- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. +- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. This partially supersedes the `ui-chat` cache ownership recorded in [client Session/Conversation ownership](../architecture/2026-08-20-client-session-conversation-ownership.md). - The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. - `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths. - A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md index f7695ab322..f1e8634639 100644 --- a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -10,7 +10,7 @@ Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: Im ## Decision -- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。 +- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 [client Session/Conversation 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `ui-chat` 缓存归属。 - 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。 - `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。 - 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index ad6907574f..cda920ea03 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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/slots.md -slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5 -slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37 +slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee +slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d201223c9e..6eb61780ca 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 23277e94e2..e5a25763d3 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 6f37d011f3..f819db101d 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../util/crypto" - }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 703675795e..81a428aa35 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1160,6 +1160,8 @@ function SourceBlocks({
)} + {/* The Raw view keeps model block order and granularity: one + gallery per image block, unlike the aggregated record gallery. */} {block.attachment !== undefined ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) :
{block.content}
} @@ -1388,11 +1390,14 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, error, + errorDetail, preview, renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean + /** Failure name and code preserved beside image-only error content. */ + errorDetail?: string | undefined preview: boolean renderImages: RenderMessageImages }) { @@ -1403,6 +1408,8 @@ function ToolOutputBlocks({ error ? css.errorPayload : undefined, ].filter((value): value is string => value !== undefined).join(' ')} > + {error && errorDetail !== undefined && errorDetail !== '' + &&
{errorDetail}
} {blocks.map((block, index) => ( block.attachment !== undefined ? ( @@ -1630,6 +1637,7 @@ function RecordPayload({ diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0283f4fde3..a811e915f2 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -120,7 +120,10 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< | 'timeSeconds' | 'startedAt' > { - const previewMarkdown = previewContent(node.content) + // An empty text block yields an empty preview; treat it as absent so an + // image-bearing record still labels its row instead of rendering blank. + const preview = previewContent(node.content) + const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) return { text: previewMarkdown === undefined && images > 0 @@ -792,7 +795,7 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } - const images = imageBlockCount(blocks.map(block => ({ type: block.kind }))) + const images = blocks.filter(block => block.kind === 'image').length if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -828,9 +831,14 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - if (type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - // Typed content only reaches here as a core ImageBlock; wire-shaped - // 'other' blocks never define `attachment`. + if ( + type === 'image' + && typeof block.attachment === 'object' && block.attachment !== null + && typeof (block.attachment as Record).attachmentId === 'string' + ) { + // Session-log content is validated into core ContentBlocks by the + // Conversation node assembly; the `attachmentId` guard only keeps + // wire-shaped 'other' blocks with an unrelated `attachment` member out. return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } return { type, content: stringifySourceValue(value) } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index bd834cbdb0..fbb454decf 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -594,6 +594,19 @@ describe('durable image attachments', () => { ]) }) + it('labels a record whose only text block is empty as image-only', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: '' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×1') + expect(user?.previewMarkdown).toBeUndefined() + }) + it('keeps the text preview when a user message mixes text and images', () => { const nodes = [ { diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 3d3955dcb3..9bd8de74f0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -1035,6 +1035,42 @@ describe('TrajectoryTable', () => { .toBe(String(attachment.attachmentId)) }) + it('keeps the failure name beside an image-only error result', () => { + const attachment = { + attachmentId: `sha256:${'c'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 320, + name: 'failed.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'render {"target":"chart"}', + outputDetail: 'ToolError: RENDER_TRUNCATED', + outputBlocks: [{ type: 'image', content: '', attachment }], + isError: true, + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy() + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( Date: Mon, 24 Aug 2026 19:10:18 +0800 Subject: [PATCH 19/37] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=BE=9D=E8=B5=96=E5=85=B3=E7=B3=BB=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 9 +++++---- docs/module-graph.zh.md | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8ba88343ec..70fff0bf9b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: aeb35195f3a095b9de694f164dc1111acf5c8197 -module-graph.zh.md: ef3bc3fbce9cad37542eeea6adf936dd70e6581b +module-graph.md: 3df7799a48682d8768661154d8c285ac2ed9af82 +module-graph.zh.md: 7cfe1b2d28fa1757a475790923144ae79ade27af diff --git a/docs/module-graph.md b/docs/module-graph.md index aeb35195f3..3df7799a48 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1444,6 +1444,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1484,7 +1485,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1531,6 +1531,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1866,15 +1867,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ef3bc3fbce..7cfe1b2d28 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1446,6 +1446,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1486,7 +1487,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1533,6 +1533,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1868,15 +1869,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | From 43ac97b554845929707f075cc29ef001fee3a173 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:06:01 +0800 Subject: [PATCH 20/37] fix(system-prompt): centralize sparse section orders --- ...system-prompt-section-order-ties.i18n.yaml | 4 +- ...-08-24-system-prompt-section-order-ties.md | 1 + ...-24-system-prompt-section-order-ties.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + ...bles-and-tool-guidance-ownership.i18n.yaml | 4 +- ...t-variables-and-tool-guidance-ownership.md | 2 +- ...ariables-and-tool-guidance-ownership.zh.md | 2 +- ...irst-party-prompt-section-orders.i18n.yaml | 6 ++ ...parse-first-party-prompt-section-orders.md | 57 +++++++++++++++ ...se-first-party-prompt-section-orders.zh.md | 57 +++++++++++++++ ...8-07-code-mode-executor-collapse.i18n.yaml | 4 +- .../2026-08-07-code-mode-executor-collapse.md | 2 +- ...26-08-07-code-mode-executor-collapse.zh.md | 2 +- ...tinuable-child-report-obligation.i18n.yaml | 4 +- ...-06-continuable-child-report-obligation.md | 2 +- ...-continuable-child-report-obligation.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +-- docs/config-catalog.zh.md | 10 +-- docs/subsystems/system-prompt.i18n.yaml | 4 +- docs/subsystems/system-prompt.md | 8 +-- docs/subsystems/system-prompt.zh.md | 8 +-- packages/boot/app-boot/src/index.ts | 9 ++- packages/boot/app-boot/tests/app-boot.spec.ts | 4 +- packages/bundle/web-app/src/index.ts | 4 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/src/index.ts | 4 +- .../context/file-reference-local/src/index.ts | 4 +- packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 10 +-- packages/core/system-prompt/README.zh.md | 10 +-- packages/core/system-prompt/src/index.ts | 71 ++++++++++++++++--- .../system-prompt/tests/system-prompt.spec.ts | 27 +++++-- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/code-mode.ts | 5 +- packages/core/tools/src/index.ts | 12 ++-- packages/core/tools/tests/code-mode.spec.ts | 24 +++++-- .../experimental/tool-agent-team/src/index.ts | 3 +- packages/extensions/tool-cordis/src/index.ts | 8 ++- packages/fs/tool-fs-search/src/glob.ts | 4 +- packages/fs/tool-fs-search/src/grep.ts | 4 +- packages/fs/tool-fs/src/edit.ts | 4 +- packages/fs/tool-fs/src/read.ts | 4 +- packages/fs/tool-fs/src/write.ts | 4 +- packages/goal/tool-goal/src/index.ts | 4 +- packages/jobs/tool-jobs/src/index.ts | 6 +- packages/lsp/tool-lsp/src/index.ts | 8 ++- packages/plan/plan-mode/src/index.ts | 4 +- .../tool-session-query/src/index.ts | 4 +- packages/shell/tool-bash/src/index.ts | 4 +- packages/shell/tool-bash/tests/tools.spec.ts | 14 +++- packages/shell/tool-pwsh/src/index.ts | 4 +- .../README.i18n.yaml | 4 +- .../subagent-in-process-driver/README.md | 2 +- .../subagent-in-process-driver/README.zh.md | 2 +- .../src/structured.ts | 7 +- .../tests/structured.spec.ts | 9 ++- packages/subagent/subagent/package.json | 2 + packages/subagent/subagent/src/child-agent.ts | 3 +- packages/subagent/subagent/tsconfig.json | 3 + .../tool-subagent-report/src/index.ts | 4 +- packages/subagent/tool-subagent/src/index.ts | 4 +- packages/terminal/tool-terminal/src/index.ts | 3 +- packages/web/tool-web/src/fetch.ts | 4 +- packages/web/tool-web/src/search.ts | 4 +- packages/workflow/tool-ralph/src/index.ts | 5 +- packages/workflow/tool-workflow/src/index.ts | 5 +- pnpm-lock.yaml | 3 + .../system-prompt.1.expected.md | 4 +- .../system-prompt.1.expected.md | 4 +- .../system-prompt.1.expected.md | 4 +- .../sdk/text-turn/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../both-mode-turn/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../code-mode-turn/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../lsp-definition/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../ralph-loop/system-prompt.1.expected.md | 4 +- .../ralph-loop/system-prompt.2.expected.md | 4 +- .../read-image/system-prompt.expected.md | 4 +- .../system-prompt.expected.md | 4 +- .../text-turn/system-prompt.expected.md | 4 +- .../web-fetch/system-prompt.expected.md | 4 +- 91 files changed, 429 insertions(+), 191 deletions(-) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml (67%) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-24-system-prompt-section-order-ties.md (98%) rename .agents/notes/{implemented => archived}/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md (98%) create mode 100644 .agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md create mode 100644 .agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml similarity index 67% rename from .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml index 17d2684a6c..b01341e7d2 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.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/bug-fix/2026-08-24-system-prompt-section-order-ties.md -2026-08-24-system-prompt-section-order-ties.md: 673c3b3cd668115ead9f9b5478c2bc432b78f930 -2026-08-24-system-prompt-section-order-ties.zh.md: 96a6843a0db48e52a2132ad9f8caf6243dfbbcd2 +2026-08-24-system-prompt-section-order-ties.md: d92756e751e893b1d03b8892ef71ff9faac9d2c6 +2026-08-24-system-prompt-section-order-ties.zh.md: 4a822b7925a38feb254dbc534fc6153c76a93e19 diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md rename to .agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md index 673c3b3cd6..d92756e751 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md @@ -1,6 +1,7 @@ # Agent Note: Equal-order system-prompt sections render in activation order Status: implemented +Archived: 2026-08-25 English | [中文](2026-08-24-system-prompt-section-order-ties.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md rename to .agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md index 96a6843a0d..4a822b7925 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md @@ -1,6 +1,7 @@ # Agent Note: 等序系统提示词分段按激活顺序渲染 Status: implemented +Archived: 2026-08-25 [English](2026-08-24-system-prompt-section-order-ties.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index aa76eab57c..bb58c4fe5a 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -112,6 +112,9 @@ "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1", + "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", + "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", + "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 13871d1cff..fcdffd69cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.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-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 9a53619d9510e3f4fa561f8420b2da3bedbbf4bb -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: dc1164198df0f92b843c75b618f140d8aef86e4f +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 361184fa7dbdaccd49ac19235c016daf5eb5ca53 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6b462572883fb69ca64f2babf28974ae59e7bd74 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 9a53619d95..361184fa7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -32,7 +32,7 @@ Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, prov ### Persona as the order-0 section -`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. +`dsh-system-prompt` owns `harness:identity` at first-party order `-1000` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The [first-party order allocation](2026-08-25-sparse-first-party-prompt-section-orders.md) owns the sparse named placements for identity, policy, tool guidance, generated protocol, and final-output obligations. ### Tool guidance ownership diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index dc1164198d..6b46257288 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -32,7 +32,7 @@ Status: implemented ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩(compaction)压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 first-party order 为 `-1000` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩(compaction)压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。[first-party 顺序分配](2026-08-25-sparse-first-party-prompt-section-orders.zh.md)规定身份、策略、工具指导、生成协议和最终输出义务的稀疏具名位置。 ### 工具指导归属 diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml new file mode 100644 index 0000000000..c7d62f839f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.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/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md +2026-08-25-sparse-first-party-prompt-section-orders.md: 2bf2e7441b449a6cfbd5b845f1d7e97b3fab09ae +2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 624ed51f4d40848c72091097900ef05ec35fdd2e diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md new file mode 100644 index 0000000000..2bf2e7441b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md @@ -0,0 +1,57 @@ +# Agent Note: Centralize sparse first-party prompt-section orders + +Status: implemented + +English | [中文](2026-08-25-sparse-first-party-prompt-section-orders.zh.md) + +## Problem + +Repository-owned system-prompt sections declared unrelated numeric literals across more than twenty packages. The main tool sequence occupied consecutive values from 100 through 117 and then used half-step values for insertions. A later change could therefore collide with an existing section without seeing the complete allocation. + +Equal orders used stable JavaScript sort behavior, which made plugin activation order the effective tie-breaker. The [Cordis/workflow prompt-order fix](../../archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md) showed that clean compositions can activate the same plugins in different orders and produce different request headers and snapshot results. Fixing one collision locally did not prevent another package from reusing that value. + +The shell guidance also followed filesystem guidance even though shell commands have the broadest execution and failure semantics. A model should read the shell result obligation before the narrower instructions that route file work to dedicated tools. + +## Decision + +`@deepseek-ai/dsh-system-prompt` exports `FIRST_PARTY_SECTION_ORDER` as the single allocation for repository-owned sections. Every first-party contributor imports its named placement instead of declaring a numeric literal. Values are unique integers, and adjacent allocated values differ by at least ten. + +The allocation preserves the established first-party sequence except for two deliberate changes: Bash, or PowerShell in the Windows composition, leads per-tool guidance; and sections that shared an order receive an explicit sequence. The groups are: + +| Group | Entries | +|---|---| +| Product opening | `harness:identity` −1000, `harness:source` −900, `app:web-surface` −800, `deployment:persona` 0 | +| Work modes | `plan:policy` 500, `team:policy` 600 | +| Invocation prelude | `tools:code-only` 800, `context:file-reference` 900 | +| Local tools | `tool:bash` 1000, `tool:pwsh` 1010, `tool:read` 1100, `tool:write` 1200, `tool:edit` 1300, `tool:glob` 1400, `tool:grep` 1500, `tool:jobs` 1600, `tool:pty` 1700 | +| Higher-level tools | `tool:web_search` 2000, `tool:web_fetch` 2100, `tool:lsp` 2200, `tool:session-query` 2300, `tool:goal` 2400, `tool:cordis` 2500, `tool:workflow` 2600, `tool:ralph` 2700, continuable-subagent guidance 2800, `tool:report` 2900 | +| Generated protocol | `tools:sdk` 5000 | +| Final-output obligations | deliverable file references 9000, `tool:structured_output` 9900 | + +`SystemPrompt.assemble()` sorts equal-order sections by code-unit section name after comparing `order`. This makes third-party collisions deterministic without locale-sensitive comparison. First-party contributors still receive distinct ranks so their intended sequence remains explicit rather than depending on the fallback. + +Dynamic `PromptContext` order and tool-schema `toolOrder` are separate sequences and remain unchanged. A scoped `deployment:persona` continues to shadow the global section by name before section sorting, so it shares `PERSONA_ORDER` rather than consuming another placement. + +## Verification + +The system-prompt unit suite verifies that every exported first-party value is an integer, every value is unique, adjacent values differ by at least ten, and opposite registration permutations produce the same code-unit name order for a tie. Real-composition snapshots pin the model-visible ordering change, including Bash before filesystem guidance and the explicit Cordis, workflow, Ralph, subagent, and report sequence. + +## Alternatives considered + +**Keep package-local numeric literals and review collisions manually.** Rejected because a contributor cannot see the complete allocation locally, and the collision that motivated the earlier fix recurred after that fix merged. + +**Continue inserting fractional values.** Rejected because fractions provide no durable spacing rule, obscure the semantic groups, and still permit unrelated packages to choose the same value. + +**Normalize only snapshot comparisons.** Rejected because the runtime request header and model prompt would remain activation-order dependent while the test hid the difference. + +**Preserve activation order for equal ranks.** Rejected because activation order is not a prompt-order decision and varies across valid compositions. Name order is deterministic for external collisions; explicit named placements carry first-party intent. + +**Renumber dynamic contexts and tool schemas in the same allocation.** Rejected because they are independently assembled sequences. Combining them would imply cross-sequence ordering that the runtime does not perform. + +## Consequences + +Numeric ranks are not rendered, so the renumbering alone does not change model text. Bash or PowerShell moves before other per-tool guidance, and previously tied sections acquire deterministic order; those model-visible changes update request-header snapshots and may invalidate provider prefix reuse from the first moved paragraph. + +An external plugin that chose a raw number specifically to sit between old first-party values may move relative to repository sections. This repository is pre-release and provides no compatibility shim for the old allocation; extensions can select positions from the exported current allocation. Equal external ranks remain supported and deterministic by name. + +The system-prompt package now knows the names and relative placement of repository features. That centralized coupling is deliberate: the registry already owns the ordering semantics, while distributed numeric literals made the same relationship implicit and uncheckable. diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md new file mode 100644 index 0000000000..624ed51f4d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 集中管理稀疏的 first-party 提示词段顺序 + +Status: implemented + +[English](2026-08-25-sparse-first-party-prompt-section-orders.md) | 中文 + +## 问题 + +仓库自带的系统提示词段分散在二十多个包中,各自声明互不关联的数字字面量。主要工具序列连续占用 100 到 117,后续插入还使用半步数值。因此,后续更改可能在无法看到完整分配表的情况下与已有段发生冲突。 + +相同 order 依赖 JavaScript 稳定排序,使插件激活顺序成为实际的平局规则。[Cordis/workflow 提示词顺序修复](../../archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md)表明,完整且有效的组合可能按不同顺序激活同一组插件,进而产生不同的请求 header 和快照结果。局部修复一次冲突,无法阻止另一个包再次使用同一数值。 + +此外,shell 指导位于文件系统指导之后,但 shell 命令具有最广泛的执行和失败语义。模型应先读到 shell 结果义务,再阅读将文件操作分流到专用工具的更窄指令。 + +## 决策 + +`@deepseek-ai/dsh-system-prompt` 导出 `FIRST_PARTY_SECTION_ORDER`,作为仓库自带提示词段的唯一分配表。每个 first-party 贡献方都导入具名位置,不再声明数字字面量。所有值都是互不相同的整数,相邻已分配值之差至少为十。 + +除两项有意调整外,该分配保留既有 first-party 顺序:Bash,或 Windows 组合中的 PowerShell,位于逐工具指导的首位;原先共享 order 的段获得明确顺序。分组如下: + +| 分组 | 条目 | +|---|---| +| 产品开场 | `harness:identity` −1000、`harness:source` −900、`app:web-surface` −800、`deployment:persona` 0 | +| 工作模式 | `plan:policy` 500、`team:policy` 600 | +| 调用前置说明 | `tools:code-only` 800、`context:file-reference` 900 | +| 本地工具 | `tool:bash` 1000、`tool:pwsh` 1010、`tool:read` 1100、`tool:write` 1200、`tool:edit` 1300、`tool:glob` 1400、`tool:grep` 1500、`tool:jobs` 1600、`tool:pty` 1700 | +| 高层工具 | `tool:web_search` 2000、`tool:web_fetch` 2100、`tool:lsp` 2200、`tool:session-query` 2300、`tool:goal` 2400、`tool:cordis` 2500、`tool:workflow` 2600、`tool:ralph` 2700、可继续运行的 subagent 指导 2800、`tool:report` 2900 | +| 生成协议 | `tools:sdk` 5000 | +| 最终输出义务 | 可交付文件引用 9000、`tool:structured_output` 9900 | + +`SystemPrompt.assemble()` 比较 `order` 后,按提示词段名称的代码单元顺序排列同号项。这样无需使用受区域设置影响的比较,也能让第三方冲突产生确定结果。first-party 贡献方仍使用不同 rank,其预期顺序由分配表明确表达,而不依赖兜底规则。 + +动态 `PromptContext` 顺序和工具 schema 的 `toolOrder` 是独立序列,保持不变。带作用域的 `deployment:persona` 仍会在段排序之前按名称遮蔽全局段,因此共享 `PERSONA_ORDER`,而不占用另一个位置。 + +## 验证 + +系统提示词单元测试验证:导出的每个 first-party 值都是整数、所有值互不重复、相邻值之差至少为十,并且顺序相反的两种注册排列会对同号项产生相同的代码单元名称顺序。真实组合快照固定面向模型的顺序变化,包括 Bash 位于文件系统指导之前,以及 Cordis、workflow、Ralph、subagent 和 report 的明确序列。 + +## 考虑过的替代方案 + +**保留包内数字字面量并通过评审人工检查冲突。**未采用,因为贡献方无法在局部看到完整分配表,而且早期修复合入后,触发该修复的同类冲突再次出现。 + +**继续插入小数值。**未采用,因为小数没有持久的间距规则,难以表达语义分组,也无法阻止无关包选择同一数值。 + +**只规范化快照比较。**未采用,因为运行时请求 header 和模型提示词仍依赖激活顺序,测试只会隐藏差异。 + +**同 rank 时保留激活顺序。**未采用,因为激活顺序不是提示词顺序决策,并且会在有效组合之间变化。名称顺序为外部冲突提供确定结果;具名位置负责表达 first-party 意图。 + +**在同一分配表中重新编号动态上下文和工具 schema。**未采用,因为运行时独立组装这些序列。合并分配会暗示运行时并不执行的跨序列顺序。 + +## 后果 + +数字 rank 不会被渲染,因此单纯重新编号不会改变模型文本。Bash 或 PowerShell 会移到其他逐工具指导之前,原先同号的段会获得确定顺序;这些面向模型的变化会更新请求 header 快照,并可能从第一个移动的段落起使提供方前缀复用失效。 + +如果外部插件专门选择一个原始数字以插入旧 first-party 数值之间,它相对仓库段的位置可能改变。本仓库处于预发布阶段,不为旧分配提供兼容层;扩展可以根据当前导出的分配表选择位置。外部段仍可使用相同 rank,并会按名称获得确定顺序。 + +系统提示词包现在了解仓库功能的名称和相对位置。这种集中耦合是有意的:注册表本就拥有排序语义,而分散的数字字面量只是让同一关系变得隐式且无法检查。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml index 967656184e..9db96229ec 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.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/bug-fix/2026-08-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: abfba369a2f6482f72d7224f6762f3ac75c8234e -2026-08-07-code-mode-executor-collapse.zh.md: 8bfdc8be6189d12b32fc260f2ab9637b8e2ba8c9 +2026-08-07-code-mode-executor-collapse.md: b19d0842d8b58b170a0f6839dcb32b9390211ca0 +2026-08-07-code-mode-executor-collapse.zh.md: 38bb319c8ce8fd01ab42ad29a6b3748c5d0f7925 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md index abfba369a2..b19d0842d8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md @@ -42,5 +42,5 @@ No provider guarantees interception of unadvertised names; the reported session - `both` and `native` behavior is unchanged; SDK sub-dispatches are unchanged (the `parent` token is the discriminator). - A collapsed call is rejected at `prepare`, BEFORE the extensible policy pipeline: pre-execute listeners, approval `ask`, and guards never observe it. `executionMode` also fails closed (`exclusive`), so scheduling has no observable difference. - Native-tool guidance sections (`tool:read`, `tool:write`, `tool:bash`, etc.) remain in the system prompt because they describe capabilities available through the generated SDK as well as native function calls, and several carry cross-tool routing policy (`read` over `bash cat`, `read` before `write` for the default fs-observation-policy, `subagent` over `workflow`) that no single tool description can hold. The executor collapse, not prompt filtering, prevents model-direct native calls. -- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of the 100-199 guidance band. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt. +- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of first-party per-tool guidance. Those sections name their tool without qualifying how it is reached, so a model that read only them emitted a native call, received `UNKNOWN_TOOL` for a tool the same prompt declared, and concluded the deployment was inconsistent rather than correcting itself. The denial carries the route for the same reason. `both` renders the rule empty: its native calls do execute, so stating it there would be false — which is why `both-mode-turn` no longer shares `code-mode-turn`'s expected prompt. - Any future composite transport that sets a `parent` token opts its sub-dispatches into the full table, matching the nested-call semantics the token already documents. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md index 8bfdc8be61..38bb319c8c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md @@ -42,5 +42,5 @@ guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装 - `both` 与 `native` 行为不变;SDK 子调用不变(判别信号是 `parent` token)。 - 被塌缩的调用在 `prepare` 阶段即被拒绝——在可扩展策略流水线之前:pre-execute 监听器、approval `ask` 与 guard 永远不会观察到它。`executionMode` 同样 fail-closed(`exclusive`),调度无可观察差异。 - 原生工具指引段(`tool:read`、`tool:write`、`tool:bash` 等)保留在系统提示词中,因为它们同时描述了通过生成 SDK 及原生函数调用可用的能力,其中若干段还承载着任何单个工具描述都装不下的跨工具路由策略(`read` 优先于 `bash cat`、默认 fs-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 -- 提示词会**声明**这条塌缩,位于排在 100–199 指导段之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 +- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 - 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml index 5a895f08e9..a3fe8365a0 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.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-08-06-continuable-child-report-obligation.md -2026-08-06-continuable-child-report-obligation.md: e771e81831147dd02a6c32a543c8d8944c2ec2f4 -2026-08-06-continuable-child-report-obligation.zh.md: e1cbb38644889459d8b8785382e1923a80c81957 +2026-08-06-continuable-child-report-obligation.md: 422d3ab74389e084becb75a145a82139dda38ac7 +2026-08-06-continuable-child-report-obligation.zh.md: ec7e19885204ddc99b2640407c605288f1e9c045 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md index e771e81831..422d3ab743 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.md @@ -15,7 +15,7 @@ Each of those choices is defensible alone. Together they made the return channel The return channel is an instruction the child receives, not a capability it may discover. The report package installs two scope-local registrations into every continuable in-process child, and one disposer revokes both: - the `report` tool, whose description now states that the child calls it once before finishing with a self-contained final result, and earlier for progress that changes what the parent should do next; -- a `tool:report` system-prompt section at order 117 carrying the same obligation in the child's own voice, so a child that never reads tool descriptions closely still receives it. +- a `tool:report` system-prompt section at first-party order 2900 carrying the same obligation in the child's own voice, so a child that never reads tool descriptions closely still receives it. `reportDelivery` defaults to `next-step`. An accepted report wakes a parked parent driver or joins a running parent's nearest step boundary, matching the instruction to report findings that change the parent's next action. `quiet` remains available for deployments that prefer unread reports over model-work amplification. The [report/settlement ordering decision](../bug-fix/2026-08-17-subagent-report-settlement-ordering.md) owns the scheduling rationale. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md index e1cbb38644..ec7e198852 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-child-report-obligation.zh.md @@ -15,7 +15,7 @@ Status: implemented 返回通道是 child 收到的一条指令,而不是它需要自行发现的能力。report 包会向每个可继续进程内 child 安装两项作用域局部注册,并由同一个 disposer 撤销两者: - `report` 工具,其描述现在说明 child 要在结束前调用一次并给出自足的最终结果,并在部分进展会改变 parent 下一步动作时提前调用; -- 一个 order 为 117 的 `tool:report` 系统提示词 section,用 child 自己的语气承载同一条义务,使从不细读工具描述的 child 仍能收到它。 +- 一个 first-party order 为 2900 的 `tool:report` 系统提示词 section,用 child 自己的语气承载同一条义务,使从不细读工具描述的 child 仍能收到它。 `reportDelivery` 的默认值为 `next-step`。一条被接受的报告会唤醒停驻的 parent driver,或加入运行中 parent 最近的 step 边界,与发现会改变 parent 下一步动作时上报的指令一致。对于宁可让报告无人阅读也要避免模型工作量放大的部署,`quiet` 依旧可用。[报告与结算顺序决策](../bug-fix/2026-08-17-subagent-report-settlement-ordering.zh.md)负责调度理由。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cec89ac365..d1463fc88f 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: 19f292ee40861ffa6f8e7fdef4d8717189a2149c -config-catalog.zh.md: 96f9721cae20a1a0ed172349c0189bc47576c75d +config-catalog.md: 6a486f8cbdac72eb1d2aedf0f728b742dd81612d +config-catalog.zh.md: e6b55350d931474e275e18b60db632021e41f63b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 19f292ee40..6a486f8cbd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -612,7 +612,7 @@ export interface Config { } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/experimental/tool-agent-team/src/index.ts:18`](../packages/experimental/tool-agent-team/src/index.ts) @@ -2468,7 +2468,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:237`](../packages/core/system-prompt/src/index.ts) @@ -2788,7 +2788,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) +Source: [`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) @@ -2953,7 +2953,7 @@ export interface Config { } ``` -Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) @@ -3021,7 +3021,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 96f9721cae..e6b55350d9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -614,7 +614,7 @@ export interface Config { } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/experimental/tool-agent-team/src/index.ts:18`](../packages/experimental/tool-agent-team/src/index.ts) @@ -2470,7 +2470,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:237`](../packages/core/system-prompt/src/index.ts) @@ -2790,7 +2790,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) +来源:[`packages/workflow/tool-ralph/src/index.ts:22`](../packages/workflow/tool-ralph/src/index.ts) @@ -2955,7 +2955,7 @@ export interface Config { } ``` -来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) @@ -3023,7 +3023,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +来源:[`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 1d1e9e3528..bf08fc3825 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.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/system-prompt.md -system-prompt.md: f4cdf40703ac4f4a6f87d525efb6010e45f4480b -system-prompt.zh.md: d3eddc3c5e0df72546f80d953e7123fa0f795816 +system-prompt.md: 68eab4f5c93f14675d548c0fa635c5f3f3ce70a7 +system-prompt.zh.md: 086555593e507da03aea3e83a55017e0aa839fcb diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index f4cdf40703..68eab4f5c9 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## Prompt sections -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. One effective `complete` section becomes the sole prompt section after cooperative assembly. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. Sections sort by ascending order and then code-unit name; `FIRST_PARTY_SECTION_ORDER` publishes the sparse named allocation for repository-owned contributions. One effective `complete` section becomes the sole prompt section after cooperative assembly. ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -47,9 +47,9 @@ interface PromptSection { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string /** - * Sections are concatenated in ascending order. Convention: `-100` is the - * harness identity, `0` the deployment persona, tool guidance uses 100–199; - * other negative orders also render before the persona. + * Sections are concatenated in ascending order. Equal orders use code-unit + * name order. Repository-owned placements use + * {@link FIRST_PARTY_SECTION_ORDER}. */ readonly order: number /** diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index d3eddc3c5e..086555593e 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## 提示词段落 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。各段先按 order 升序排列,再按名称的代码单元顺序排列;`FIRST_PARTY_SECTION_ORDER` 公开仓库自带贡献的稀疏具名分配表。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -47,9 +47,9 @@ interface PromptSection { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string /** - * Sections are concatenated in ascending order. Convention: `-100` is the - * harness identity, `0` the deployment persona, tool guidance uses 100–199; - * other negative orders also render before the persona. + * Sections are concatenated in ascending order. Equal orders use code-unit + * name order. Repository-owned placements use + * {@link FIRST_PARTY_SECTION_ORDER}. */ readonly order: number /** diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9a1d05ffbf..e834ae3934 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -18,8 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' -// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { interface Context { @@ -826,8 +825,8 @@ export const HARNESS_SOURCE_SECTION = 'harness:source' * explicitly distinguishing it from the task workspace and current working * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this * checkout. Call once on the settled boot context ({@link boot}); the section - * orders just after the harness identity opener (`-100`) and before the deployment - * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to + * uses the shared first-party placement just after the harness identity opener + * and before the deployment persona. A booted tree with no `systemPrompt` service has no prompt to * augment, so this is then a no-op that returns `undefined`. The section is * registered against the `systemPrompt` service's fiber, so a dev HMR reload of * that plugin drops it until the next boot. @@ -840,7 +839,7 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() = if (systemPrompt === undefined) return undefined return systemPrompt.section({ name: HARNESS_SOURCE_SECTION, - order: -99, + order: FIRST_PARTY_SECTION_ORDER.HARNESS_SOURCE, text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, }) } diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 8ae1a21cd2..e5fc24aaf3 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -817,8 +817,8 @@ describe('addHarnessSourceSection', () => { const systemPrompt = ctx.get('systemPrompt')! const rendered = renderPrompt(await systemPrompt.assemble()) expect(rendered).toContain(EXPECTED) - // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards - // keep a drifted opener/persona string from a false pass through `-1 < n`. + // The >= 0 guards keep a drifted opener/persona string from a false pass + // through `-1 < n`. const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.') const sourceAt = rendered.indexOf(EXPECTED) const personaAt = rendered.indexOf('You are a coding agent.') diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 79d1e94862..713f6d10a4 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -24,7 +24,7 @@ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-shell-env' /** Stable Cordis plugin name. */ @@ -243,7 +243,7 @@ export function apply(ctx: Context, config: Config): void { addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', - order: -98, + order: FIRST_PARTY_SECTION_ORDER.WEB_SURFACE, text: () => webSurfacePrompt(localWebUrl(promptCtx)), }) }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 77799f1afb..a9c6ad050a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: ace08fae3a001080918973c23aa362080cd69066 -README.zh.md: 9df1664bc42c39012c2eebf397cb368b3b5c6260 +README.md: 58d68746c9d34f05c103430f330a042626213509 +README.zh.md: a62421eef8d524e0f5b65aa6d11d6db0c42c02da diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ace08fae3a..58d68746c9 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -26,7 +26,7 @@ One fixed prompt paragraph whenever this package is loaded; no tool schema, tool #### KV Cache effect -The section is static at order 190 for the lifetime of the package mount, so it remains in the reusable prompt prefix and does not change across Turns. +The section is static at first-party order 9000 for the lifetime of the package mount, so it remains in the reusable prompt prefix and does not change across Turns. ## Known Limitations and Deferred Work diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9df1664bc4..a62421eef8 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -26,7 +26,7 @@ Node 侧注册静态系统提示词段落 `ui:deliverable-file-references`。它 #### KV Cache 影响 -该段落在本包加载期间始终以顺序 190 保持静态,因此留在可复用的提示词前缀中,不会随 Turn 改变。 +该段落在本包加载期间始终以 first-party 顺序 9000 保持静态,因此留在可复用的提示词前缀中,不会随 Turn 改变。 ## 已知限制与暂缓事项 diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 6e6c387803..4f86a5f6a1 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -6,7 +6,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' /** Services required for the model guidance paired with the browser renderer. */ export const inject = ['systemPrompt'] @@ -22,7 +22,7 @@ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, men export function apply(ctx: Context): void { ctx.systemPrompt.section({ name: 'ui:deliverable-file-references', - order: 190, + order: FIRST_PARTY_SECTION_ORDER.DELIVERABLE_FILE_REFERENCES, text: FILE_REFERENCE_PROMPT, }) } diff --git a/packages/context/file-reference-local/src/index.ts b/packages/context/file-reference-local/src/index.ts index 95d1cfec2d..4006e01e41 100644 --- a/packages/context/file-reference-local/src/index.ts +++ b/packages/context/file-reference-local/src/index.ts @@ -11,7 +11,7 @@ import FileReferenceService, { FILE_REFERENCE_PROMPT, type FileReferenceCandidate, } from '@deepseek-ai/dsh-file-reference' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, @@ -69,7 +69,7 @@ export class LocalFileReferenceService extends FileReferenceService { const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => { scope.systemPrompt.section({ name: 'context:file-reference', - order: 99, + order: FIRST_PARTY_SECTION_ORDER.FILE_REFERENCE, text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, }) }) diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index c34ff9547a..863c5ad548 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/core/system-prompt/README.md -README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 -README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee +README.md: a76191c9bc68c73bb4edff07034837e90fc9f35a +README.zh.md: 34bf770a2f0a4458c0749bc25eea96d2857eda47 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index a52aa3e4c2..a76191c9bc 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -8,7 +8,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | Key | Default | Meaning | |---|---|---| -| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | +| `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` first-party opener at order −1000. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | | `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | @@ -18,7 +18,7 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Sections sort by ascending order, then code-unit name for equal orders. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.context(context: PromptContext): () => void` Contribute ordered dynamic context for the calling scope. Providers are evaluated for each eligible assembly and become a sourced runtime-context snapshot in model history under the shipped loop. - `ctx.systemPrompt.suppressRuntimeContext(): () => void` Suppress every dynamic-context contribution for the calling scope. Multiple registrations compose independently; disposing the returned effect restores context when no suppressor remains. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. @@ -32,7 +32,8 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. -- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly. +- `FIRST_PARTY_SECTION_ORDER` — the sparse named allocation for repository-owned sections. Values are unique integers whose adjacent allocated positions differ by at least ten; external sections may use any finite order. +- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`, with equal values ordered by code-unit `name`. One effective `complete` section suppresses all other sections after cooperative assembly. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -45,7 +46,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Tool schema providers: `ToolRuntime` registers itself as a tool provider automatically. - The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced. -Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) and [the first-party order allocation](../../../.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md). ## Model Experience @@ -88,4 +89,3 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged. - **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona default, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API. - **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one. - **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load. -- **Sections sharing an `order` value tie-break by registration order** — a plugin-load artifact; determinism relies on the distinct-order band convention, unlike the canonicalized tool order. diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index cf0ba43aa2..34bf770a2f 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -8,7 +8,7 @@ | 键 | 默认值 | 含义 | |---|---|---| -| `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | +| `includeHarnessIdentity` | `true` | 是否包含顺序为 −1000 的 first-party 固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | | `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | @@ -18,7 +18,7 @@ ### 公开 API -- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。各段先按 order 升序排列,同号时再按名称的代码单元顺序排列。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 - `ctx.systemPrompt.context(context: PromptContext): () => void`:为调用作用域贡献有序动态上下文。每次符合条件的组装都会求值提供方,并在随附循环下成为模型历史中带来源的 runtime-context 快照。 - `ctx.systemPrompt.suppressRuntimeContext(): () => void`:抑制调用作用域的所有动态上下文贡献。多个注册会独立组合;只有当不再存在抑制器时,dispose 返回的 effect 才会恢复上下文。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 @@ -34,7 +34,8 @@ ### 关键类型 - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 -- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段。 +- `FIRST_PARTY_SECTION_ORDER`:仓库自带提示词段的稀疏具名分配表。所有值都是互不相同的整数,相邻已分配位置之差至少为十;外部段可以使用任意有限 order。 +- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接,同号时按代码单元 `name` 排列。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段。 - `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`。各段文本到达时已求值,但尚未插值;`variables` 保存所有已注册变量在当前上下文中求得的值。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或出现 `{{` 却没有形成完整组、而后文仍有 `}}`(`{{{model}}}`),都会抛出异常;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 @@ -47,7 +48,7 @@ - 工具 schema 提供方:`ToolRuntime` 自动将自身注册为工具提供方。 - [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。 -设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)。 +设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)与 [first-party 顺序分配](../../../.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md)。 ## 模型体验 @@ -90,4 +91,3 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全 - **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。 - **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。 - **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。 -- **共享同一 `order` 值的段按注册顺序打破平局**:这是插件加载产物;确定性依赖在顺序分段内使用不同值的约定,与已规范化的工具顺序不同。 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ec36b32432..4ac0010df9 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -54,9 +54,9 @@ export interface PromptSection { /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string /** - * Sections are concatenated in ascending order. Convention: `-100` is the - * harness identity, `0` the deployment persona, tool guidance uses 100–199; - * other negative orders also render before the persona. + * Sections are concatenated in ascending order. Equal orders use code-unit + * name order. Repository-owned placements use + * {@link FIRST_PARTY_SECTION_ORDER}. */ readonly order: number /** @@ -119,6 +119,47 @@ export interface PromptAssembly { variables: Record } +/** + * Sparse integer placements for repository-owned prompt sections. + * + * Adjacent values differ by at least ten so a new first-party section can be + * inserted without renumbering the surrounding sequence. + * External plugins may use any finite order; equal orders are deterministic by + * section name. + */ +export const FIRST_PARTY_SECTION_ORDER = { + HARNESS_IDENTITY: -1000, + HARNESS_SOURCE: -900, + WEB_SURFACE: -800, + DEPLOYMENT_PERSONA: 0, + PLAN_POLICY: 500, + TEAM_POLICY: 600, + CODE_ONLY: 800, + FILE_REFERENCE: 900, + TOOL_BASH: 1000, + TOOL_PWSH: 1010, + TOOL_READ: 1100, + TOOL_WRITE: 1200, + TOOL_EDIT: 1300, + TOOL_GLOB: 1400, + TOOL_GREP: 1500, + TOOL_JOBS: 1600, + TOOL_PTY: 1700, + TOOL_WEB_SEARCH: 2000, + TOOL_WEB_FETCH: 2100, + TOOL_LSP: 2200, + TOOL_SESSION_QUERY: 2300, + TOOL_GOAL: 2400, + TOOL_CORDIS: 2500, + TOOL_WORKFLOW: 2600, + TOOL_RALPH: 2700, + TOOL_SUBAGENT: 2800, + TOOL_REPORT: 2900, + TOOLS_SDK: 5000, + DELIVERABLE_FILE_REFERENCES: 9000, + STRUCTURED_OUTPUT: 9900, +} as const + /** * The deployment persona's section name and order. Exported because a * composition can replace this slot — an agent preset shadows the @@ -127,8 +168,8 @@ export interface PromptAssembly { */ export const PERSONA_SECTION = 'deployment:persona' -/** Prompt order of the persona slot; the first section a model reads. */ -export const PERSONA_ORDER = 0 +/** Prompt order of the persona slot. */ +export const PERSONA_ORDER = FIRST_PARTY_SECTION_ORDER.DEPLOYMENT_PERSONA /** Valid variable names: how they are written between the braces. */ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ @@ -177,9 +218,19 @@ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownN name === TOOL_ORDER_REST ? rest : tools.filter(tool => tool.name === name)) } -/** Lexicographic (code-unit) name comparison — locale-independent, so the order is identical on every machine. */ +/** Code-unit name comparison — locale-independent, so the order is identical on every machine. */ +function compareNames(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +/** Order prompt sections by their explicit placement, then deterministically by name. */ +function comparePromptSections(a: PromptSection, b: PromptSection): number { + return a.order - b.order || compareNames(a.name, b.name) +} + +/** Order tool schemas lexicographically by name. */ function compareToolNames(a: ToolSchema, b: ToolSchema): number { - return a.name < b.name ? -1 : a.name > b.name ? 1 : 0 + return compareNames(a.name, b.name) } /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ @@ -360,7 +411,7 @@ export class SystemPrompt extends Service { if (config.includeHarnessIdentity ?? true) { this.section({ name: 'harness:identity', - order: -100, + order: FIRST_PARTY_SECTION_ORDER.HARNESS_IDENTITY, text: 'You are an AI agent powered by DeepSeek Harness.', }) } @@ -484,7 +535,7 @@ export class SystemPrompt extends Service { variables[name] = provider(context) } } - // Scoped sections shadow globals before the stable order sort. + // Scoped sections shadow globals before the deterministic order sort. const sectionByName = this.layers.merge(scope, layer => layer.sections) const contextByName = this.layers.merge(scope, layer => layer.contexts) // Validate order against pre-restriction names while collecting visible schemas. @@ -505,7 +556,7 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } - const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order) + const sectionDefinitions = [...sectionByName.values()].sort(comparePromptSections) const completeSections = sectionDefinitions.filter(section => section.complete === true) if (completeSections.length > 1) { throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index c4018103d3..d903435718 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { + AssembleContext, FIRST_PARTY_SECTION_ORDER, PromptAssembly, renderContextSnapshot, renderPrompt, +} from '@deepseek-ai/dsh-system-prompt' /** * Every assembly carries the plugin's own built-ins — `harness:identity` - * (order −100) and `deployment:persona` (order 0, from config). Tests about + * and `deployment:persona` (from config). Tests about * registry MECHANICS strip them with {@link contributed} to stay focused on * their own sections; the built-ins' behavior is pinned by its own describe. */ @@ -15,6 +17,14 @@ function contributed(assembly: PromptAssembly): PromptAssembly['sections'] { } describe('SystemPrompt', () => { + it('keeps first-party section placements unique, integral, and at least ten apart', () => { + const orders = Object.values(FIRST_PARTY_SECTION_ORDER) + expect(orders.every(Number.isInteger)).toBe(true) + expect(new Set(orders).size).toBe(orders.length) + const sorted = [...orders].sort((a, b) => a - b) + expect(sorted.slice(1).every((order, index) => order - sorted[index]! >= 10)).toBe(true) + }) + describe('built-in sections', () => { it('registers the harness identity and the configured deployment persona', async () => { const ctx = new Context() @@ -115,6 +125,15 @@ describe('SystemPrompt', () => { expect(renderContextSnapshot(assembly)).toBe('Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncontext 1\n\ncontext 2') }) + it('breaks equal section orders by code-unit name regardless of registration order', async () => { + for (const names of [['äther', 'zeta'], ['zeta', 'äther']] as const) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + for (const name of names) ctx.systemPrompt.section({ name, order: 10, text: name }) + expect(contributed(await ctx.systemPrompt.assemble()).map(section => section.name)).toEqual(['zeta', 'äther']) + } + }) + it('resolves section text providers against the assemble context, at each assemble call', async () => { // The context is HOW per-agent sections work (the loop passes { agent }); // this spec stays agent-agnostic and smuggles a marker through a plain field. @@ -262,7 +281,7 @@ describe('SystemPrompt', () => { it('composes multiple system-prompt/assemble waterfall listeners in order, with the context', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' }) + ctx.systemPrompt.section({ name: 'base', order: 10, text: 'base' }) // Listener A appends a section, then delegates. const contexts: AssembleContext[] = [] @@ -329,7 +348,7 @@ describe('SystemPrompt', () => { it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' }) + ctx.systemPrompt.section({ name: 'base', order: 10, text: 'base' }) ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] })) const first = await ctx.systemPrompt.assemble() diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 571012276f..f94a3ebeab 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: a140255a04187d4f2206df0f90c950d7608ee8ac -README.zh.md: f519ba0a364f05ab63967c07ffd6681a5f62a70a +README.md: 98f5d260e023e6b301590bbb4e4eda3beb7ee9ff +README.zh.md: 98045426b4c18317cd7685a907ce7042ca322269 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a140255a04..98f5d260e0 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -119,7 +119,7 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). +- **The SDK section** (`tools:sdk`, first-party order 5000): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). - **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch, scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry and every successful final content sequence containing an image is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and source attribution even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. - **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index f519ba0a36..98045426b4 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -121,7 +121,7 @@ ctx.tools.register(defineTool({ 在 `code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token,不受此限制,因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 -- **SDK 段**(`tools:sdk`,顺序 150):一个在组装时求值的提示词段,每次组装都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态会生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明,以及映射调用作用域最终可见工具的 `tools` 命名空间(特殊名称使用带引号的键),并附带固定的使用说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 +- **SDK 段**(`tools:sdk`,first-party 顺序 5000):一个在组装时求值的提示词段,每次组装都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态会生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明,以及映射调用作用域最终可见工具的 `tools` 命名空间(特殊名称使用带引号的键),并附带固定的使用说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 - **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON,经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联使按提交语义工作的观察器可以延后提交内部调用的成功结果,直到最终 `run_code` 结果确定,而无需暴露进行中的外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目以及每份成功且含图片的最终内容序列都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系和来源归属,即使程序后来失败也不例外。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 - **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果封装语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有这个外层结果可以按常规 spill 机制处理。 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index ad0e59b77e..729163b95c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -11,6 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_RUNTIME_SCHEDULER } from './index.ts' import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRuntime, ToolRunContext } from './index.ts' @@ -19,8 +20,8 @@ import type {} from './types.ts' /** The model-facing name of the Code Mode tool. */ export const RUN_CODE_NAME = 'run_code' -/** The `tools:sdk` section order: inside the 100–199 tool-guidance band, after per-tool guidance sections. */ -export const SDK_SECTION_ORDER = 150 +/** The `tools:sdk` section order, after per-tool guidance sections. */ +export const SDK_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOLS_SDK /** * The language-specific `run_code` schema text: the tool `description` and its diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 3901480cb0..b51579e8ee 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -13,7 +13,7 @@ import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, UserMessage } from '@deepseek-ai/dsh-session' -import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER, type ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. @@ -45,10 +45,10 @@ import { renderToolsSdkPy } from './py-types.ts' */ /** * Prompt order of the `code` collapse statement: after the persona and before - * the 100-199 per-tool guidance band, so the model reads which tools it may - * call before it reads what each one is for. + * per-tool guidance, so the model reads which tools it may call before it + * reads what each one is for. */ -const COLLAPSE_SECTION_ORDER = 99 +const COLLAPSE_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.CODE_ONLY /** * The model-facing statement of the `code` collapse. Names the consequence @@ -842,8 +842,8 @@ export class ToolRuntime extends Service { * {@link sdkSection} is and rendering empty outside an effective `code`. * * Every tool contributes its own guidance section naming its tool, none of - * them qualify how that tool is reached, and they all render before the SDK - * (orders 100-199 against {@link SDK_SECTION_ORDER}). Without this the model + * them qualify how that tool is reached, and they all render before the SDK. + * Without this the model * reads a catalog of tools it is told to use and no statement that only * `run_code` may be called, so it emits a native call, receives * `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes the diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 98d849f097..563dffbf9f 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' @@ -138,9 +138,13 @@ describe('mode-aware wire contribution', () => { it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => { const { ctx, systemPrompt } = await setup({ mode: 'code' }) registerEcho(ctx) - // Stand in for a real tool's guidance section, which sits in the 100-199 - // band and names its tool without saying how it is reached. - ctx.systemPrompt.section({ name: 'tool:echo', order: 100, text: 'Use the echo tool.' }) + // Stand in for a real tool's guidance section, which names its tool without + // saying how it is reached. + ctx.systemPrompt.section({ + name: 'tool:echo', + order: FIRST_PARTY_SECTION_ORDER.TOOL_READ, + text: 'Use the echo tool.', + }) const assembly = await systemPrompt.assemble() const names = assembly.sections.map(section => section.name) @@ -206,7 +210,11 @@ describe('mode-aware wire contribution', () => { const { ctx, systemPrompt } = await setup({ mode }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: 150, text: 'SCOPED SDK' }) + scope.ctx.systemPrompt.section({ + name: 'tools:sdk', + order: FIRST_PARTY_SECTION_ORDER.TOOLS_SDK, + text: 'SCOPED SDK', + }) const scoped = await systemPrompt.assemble({ scope: agent }) const global = await systemPrompt.assemble() @@ -290,7 +298,11 @@ describe('mode-aware wire contribution', () => { expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) - scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' }) + scope.ctx.systemPrompt.section({ + name: 'scoped-note', + order: FIRST_PARTY_SECTION_ORDER.TOOLS_SDK - 10, + text: 'safe note', + }) scope.ctx.tools.register(defineContentToolFixture({ name: 'scoped_safe', description: 'Safe scoped tool.', diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts index fa2802828a..56341a6206 100644 --- a/packages/experimental/tool-agent-team/src/index.ts +++ b/packages/experimental/tool-agent-team/src/index.ts @@ -5,6 +5,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { TeamTaskId } from '@deepseek-ai/dsh-experimental-agent-team' import type { TeamMemberView } from '@deepseek-ai/dsh-experimental-agent-team' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool } from '@deepseek-ai/dsh-tools' import type { InferValue, ValueSchemaSpec } from '@deepseek-ai/dsh-tools' @@ -163,7 +164,7 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo try { register(scoped.systemPrompt.section({ name: 'team:policy', - order: 60, + order: FIRST_PARTY_SECTION_ORDER.TEAM_POLICY, text: () => { const membership = ctx.agentTeams.membership(agent) return `${POLICY}\n\nYour Team role is ${membership.role}; your Team name is ${membership.name}; Team id is ${membership.id}.` diff --git a/packages/extensions/tool-cordis/src/index.ts b/packages/extensions/tool-cordis/src/index.ts index d760797a5d..e090eb993d 100644 --- a/packages/extensions/tool-cordis/src/index.ts +++ b/packages/extensions/tool-cordis/src/index.ts @@ -14,7 +14,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolExecution } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { missingServices, providedServices } from './inspect.ts' import { presentDefineCall, presentInspectListCall, presentInspectQueryCall, presentInspectSelfCall, presentRunCall, @@ -33,7 +33,11 @@ function requireAgent(exec: ToolExecution): Agent { /** Register the Cordis tools and explicit `@pluginId` context injection. */ export function apply(ctx: Context): void { - ctx.systemPrompt.section({ name: 'tool:cordis', order: 115.5, text: CORDIS_SYSTEM_PROMPT }) + ctx.systemPrompt.section({ + name: 'tool:cordis', + order: FIRST_PARTY_SECTION_ORDER.TOOL_CORDIS, + text: CORDIS_SYSTEM_PROMPT, + }) for (const provider of hostInspectProviders(ctx)) { ctx.effect(() => ctx.cordisInspect.register(provider), `tool-cordis: inspect ${provider.manifest.id}`) } diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 871dc16ae8..39a5fabd1d 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -14,7 +14,7 @@ import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { globSearchMeta, searchViewFromMeta } from './presentation.ts' import { acceptedDirectCallValue } from './direct-call.ts' @@ -300,7 +300,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { : 'while a larger one keeps the modification-time-ordered head.' ctx.systemPrompt.section({ name: 'tool:glob', - order: 103, + order: FIRST_PARTY_SECTION_ORDER.TOOL_GLOB, text: 'Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. ' + `Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`, }) diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index 38f54aa2a5..0d56c0ae63 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -16,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-output-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type { GrepMatch } from './search-core.ts' import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' import { grepSearchMeta, searchViewFromMeta } from './presentation.ts' @@ -275,7 +275,7 @@ export function presentGrepResult( export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { ctx.systemPrompt.section({ name: 'tool:grep', - order: 104, + order: FIRST_PARTY_SECTION_ORDER.TOOL_GREP, text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.', }) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index b9fc89d4ed..60f3913866 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -9,7 +9,7 @@ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' @@ -76,7 +76,7 @@ export function formatEditOutput(displayPath: string, replaceAll: boolean): stri export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void { ctx.systemPrompt.section({ name: 'tool:edit', - order: 102, + order: FIRST_PARTY_SECTION_ORDER.TOOL_EDIT, text: 'Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.', }) diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index cc9bd4e937..0ec11074f8 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' import { resolveRegularReadTarget } from './read-target.ts' @@ -69,7 +69,7 @@ export function parseReadArgs(args: { file_path: string; offset?: number; limit? export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.systemPrompt.section({ name: 'tool:read', - order: 100, + order: FIRST_PARTY_SECTION_ORDER.TOOL_READ, text: 'Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.', }) diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index c7a7e555d2..20bdb2671f 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -10,7 +10,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta } from './diff.ts' import { remediateFsError } from './error.ts' import { sessionResolveOptions } from './session-cwd.ts' @@ -62,7 +62,7 @@ interface WriteToolArgs { export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void { ctx.systemPrompt.section({ name: 'tool:write', - order: 101, + order: FIRST_PARTY_SECTION_ORDER.TOOL_WRITE, text: 'Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.', }) diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 903190de4d..ac13ea9213 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -11,7 +11,7 @@ import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { completionAuthority, goalToolExecution, @@ -188,7 +188,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:goal', - order: 114, + order: FIRST_PARTY_SECTION_ORDER.TOOL_GOAL, text: guidance(resolved.blockedAfterConsecutiveRounds), }) diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index a04847b09a..3424abc3ef 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -15,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' export const name = 'tool-jobs' @@ -259,10 +259,10 @@ export function apply(ctx: Context, config: Config): void { // Producers may start work only while a controller is attached. ctx.jobs.attachController('tool-jobs') - // Cross-call guidance follows the bash section and precedes product sections. + // Cross-call guidance follows the filesystem sections and precedes product sections. ctx.systemPrompt.section({ name: 'tool:jobs', - order: 106, + order: FIRST_PARTY_SECTION_ORDER.TOOL_JOBS, text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job\'s work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.', }) diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index f2461a7539..b958688d5a 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -16,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever } from '@deepseek-ai/dsh-llm' import { LspError } from '@deepseek-ai/dsh-lsp' import type {} from '@deepseek-ai/dsh-lsp' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { DEFAULT_MAX_LOCATIONS, @@ -101,7 +101,11 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('maxResultChars', resolved.maxResultChars) assertTimer('timeoutMs', resolved.timeoutMs) - ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT }) + ctx.systemPrompt.section({ + name: 'tool:lsp', + order: FIRST_PARTY_SECTION_ORDER.TOOL_LSP, + text: LSP_PROMPT_TEXT, + }) ctx.tools.register(defineTool({ name: 'lsp', diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index d81865932c..1664ccdaf4 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -30,7 +30,7 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' // Type-only edge: resolves `ctx.commands` for the optional command child. import type { CommandId } from '@deepseek-ai/dsh-commands' @@ -242,7 +242,7 @@ export class PlanModeController extends Service { ctx.systemPrompt.section({ name: 'plan:policy', - order: 50, + order: FIRST_PARTY_SECTION_ORDER.PLAN_POLICY, text: (context) => { if (context.agent === undefined) return '' const pending = this.pendingIntents.get(context.agent.session) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index d204184cfe..efba41849c 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { toolInput } from './input.ts' import { operations } from './operations.ts' import { presentation } from './presentation.ts' @@ -59,7 +59,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', - order: 113, + order: FIRST_PARTY_SECTION_ORDER.TOOL_SESSION_QUERY, text: PROMPT_TEXT, }) diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 37c4a762e3..4c3069a10c 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -15,7 +15,7 @@ import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-shell-env' @@ -235,7 +235,7 @@ export function apply(ctx: Context, config: Config = {}): void { // Cross-call guidance belongs in the prompt rather than one-call schema prose. ctx.systemPrompt.section({ name: 'tool:bash', - order: 105, + order: FIRST_PARTY_SECTION_ORDER.TOOL_BASH, text: 'Check the [exit code: N] marker on every bash result; investigate failures before moving on.', }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 0f2e4d5a20..40314ef5fb 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult } from '@deepseek-ai/dsh-shell' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -377,8 +377,16 @@ describe('bash tool', () => { it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => { const ctx = await setup() - ctx.systemPrompt.section({ name: 'test:before-bash', order: 104, text: 'before' }) - ctx.systemPrompt.section({ name: 'test:after-bash', order: 106, text: 'after' }) + ctx.systemPrompt.section({ + name: 'test:before-bash', + order: FIRST_PARTY_SECTION_ORDER.TOOL_BASH - 10, + text: 'before', + }) + ctx.systemPrompt.section({ + name: 'test:after-bash', + order: FIRST_PARTY_SECTION_ORDER.TOOL_BASH + 10, + text: 'after', + }) const assembly = await ctx.systemPrompt.assemble() const section = assembly.sections.find(s => s.name === 'tool:bash') expect(assembly.sections.map(s => s.name)).toEqual([ diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 603322e381..10d7d40ed5 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -26,7 +26,7 @@ import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-jobs' import type {} from '@deepseek-ai/dsh-shell-env' import type {} from '@deepseek-ai/dsh-user-approval' @@ -243,7 +243,7 @@ export function apply(ctx: Context, config: Config = {}): void { ctx.systemPrompt.section({ name: 'tool:pwsh', - order: 105, + order: FIRST_PARTY_SECTION_ORDER.TOOL_PWSH, text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', }) diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 16d07d8211..2ba6cce30b 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/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/subagent/subagent-in-process-driver/README.md -README.md: ed2568fcff3fe1f0f3968d1cef43ebd914a8911b -README.zh.md: f9958e5c2b819d51bfdf8fc1e14d1f8c7c19be91 +README.md: 5a4ae0a38c34de5c01a5c8d80f2965bb5cb34150 +README.zh.md: 8cc5b37511b565d9899ba572b1c7322ea9fdf598 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index ed2568fcff..5a4ae0a38c 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -39,7 +39,7 @@ Depth enforcement is internal to `startInProcessRun`: it reads the parent depth `attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope: - A `structured_output` tool registered with the requested schema validates and stages the model's value. -- An order-190 system-prompt section tells the child that the tool call is the terminal answer. +- A trailing first-party order-9900 system-prompt section tells the child that the tool call is the terminal answer. - Both contributions are ordinary child-scoped registrations. An expert `system-prompt/assemble` listener may replace them and therefore owns preserving the structured-output protocol for that child. - A `tools/result` observer commits a staged value only after that execution's authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch. - A monotonic tool guard blocks later calls after capture, and the structured-output execution's `concludeTurn()` marker ends the turn after the result commits. diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index f9958e5c2b..8cc5b37511 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -39,7 +39,7 @@ `attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整约定: - 使用请求 schema 注册的 `structured_output` 工具会校验并暂存模型值。 -- 一个顺序为 190 的系统提示词段会告诉子 agent,该工具调用就是终态答案。 +- 一个位于末尾、first-party 顺序为 9900 的系统提示词段会告诉子 agent,该工具调用就是终态答案。 - 两项贡献都是普通的子 agent 作用域注册。专家级 `system-prompt/assemble` 监听器可以替换它们,因此负责为该子 agent 保留结构化输出协议。 - `tools/result` 观察器只会在该次执行的权威最终工具结果成功后提交暂存值;Code Mode 子分派外层的 `run_code` 结果也包括在内。 - 单调工具防护会在捕获值后阻止后续调用,结构化输出执行的 `concludeTurn()` 标记则在结果提交后结束轮次。 diff --git a/packages/subagent/subagent-in-process-driver/src/structured.ts b/packages/subagent/subagent-in-process-driver/src/structured.ts index e5d8c67a79..1b764d7a4c 100644 --- a/packages/subagent/subagent-in-process-driver/src/structured.ts +++ b/packages/subagent/subagent-in-process-driver/src/structured.ts @@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -19,8 +20,8 @@ import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@ export const STRUCTURED_OUTPUT_TOOL = 'structured_output' /** - * The instruction registered as the child's trailing (order-190, the end of - * the tool-guidance band) scoped prompt section: the demand travels with the + * The instruction registered as the child's trailing scoped prompt section: + * the demand travels with the * tool, as ordinary prompt state of exactly one agent. */ export const STRUCTURED_OUTPUT_INSTRUCTION @@ -98,7 +99,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch childCtx.systemPrompt.section({ name: `tool:${STRUCTURED_OUTPUT_TOOL}`, - order: 190, + order: FIRST_PARTY_SECTION_ORDER.STRUCTURED_OUTPUT, text: STRUCTURED_OUTPUT_INSTRUCTION, }) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 6bb18f5f28..78a4068d95 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -5,6 +5,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' @@ -552,14 +553,18 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 7 }), ]) // A global tool sorts lexicographically after structured_output, while a - // global section above the 190 band follows the capture instruction. + // global section after the final-output slot follows the capture instruction. ctx.tools.register(defineContentToolFixture({ name: 'zz_probe', description: 'probe', parameters: {}, execute: () => Promise.resolve([{ type: 'text', text: 'x' }]), })) - ctx.systemPrompt.section({ name: 'after-band', order: 200, text: 'AFTER-BAND' }) + ctx.systemPrompt.section({ + name: 'after-band', + order: FIRST_PARTY_SECTION_ORDER.STRUCTURED_OUTPUT + 10, + text: 'AFTER-BAND', + }) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) await run.result const request = adapter.requests[0]! diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 293089b84d..02a2abaf02 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", @@ -96,6 +97,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index 22c9e77bf5..58709dee12 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import { PERSONA_ORDER } from '@deepseek-ai/dsh-system-prompt' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' // Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve // to the policy services when composed — delegation consumes both @@ -204,7 +205,7 @@ export function applyChildComposition( // Order 120: after the sandbox:policy (110) and approval:policy (115) sentences. childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { - childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) + childCtx.systemPrompt.section({ name: 'deployment:persona', order: PERSONA_ORDER, text: composition.persona }) } if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index b64e58a6cd..ae39b9a78b 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/scope" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 85b139e641..fb484e8a2d 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -11,7 +11,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-subagent-report' @@ -21,7 +21,7 @@ export const name = 'tool-subagent-report' export const inject = ['subagents', 'tools', 'systemPrompt'] /** Guidance order after every per-tool section a continuable child can carry. */ -const REPORT_SECTION_ORDER = 117 +const REPORT_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOL_REPORT /** Config: how accepted reports are scheduled on the parent. */ export interface Config { diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ceb04cced8..f71bdad511 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -23,7 +23,7 @@ import { } from '@deepseek-ai/dsh-subagent' import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai/dsh-subagent' import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { hasConfiguredLlmSelection, hasDelegationModelRequest, @@ -42,7 +42,7 @@ export const name = 'tool-subagent' export const inject = ['tools', 'subagents', 'systemPrompt'] /** Prompt order after bounded delegation policy and before child reporting. */ -const SUBAGENT_SECTION_ORDER = 116.5 +const SUBAGENT_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.TOOL_SUBAGENT /** Config: which registered provider this tool delegates to, plus child defaults. */ export interface Config { diff --git a/packages/terminal/tool-terminal/src/index.ts b/packages/terminal/tool-terminal/src/index.ts index b08938d5e9..8d063f288e 100644 --- a/packages/terminal/tool-terminal/src/index.ts +++ b/packages/terminal/tool-terminal/src/index.ts @@ -11,6 +11,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { TerminalSendResult, TerminalSessionId as TerminalSessionIdType, TerminalSignal } from '@deepseek-ai/dsh-terminal' import type {} from '@deepseek-ai/dsh-jobs' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import { boundTerminalText, renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts' @@ -155,7 +156,7 @@ export function apply(ctx: Context, config: Config = {}): void { } ctx.systemPrompt.section({ name: 'tool:pty', - order: 106, + order: FIRST_PARTY_SECTION_ORDER.TOOL_PTY, text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.', }) diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 05637ea19f..d5274479c7 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -12,7 +12,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' /** * The shared HTML→markdown converter: turndown over its bundled domino DOM, @@ -429,7 +429,7 @@ export function presentFetchResult(args: { url: string }, result: ToolResult): W export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChars: number): void { ctx.systemPrompt.section({ name: 'tool:web_fetch', - order: 111, + order: FIRST_PARTY_SECTION_ORDER.TOOL_WEB_FETCH, text: 'Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content.', }) diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index f382582172..a17fcb6b6b 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -9,7 +9,7 @@ import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' /** * Default upper bound on returned sources (the `searchMaxResults` config). @@ -313,7 +313,7 @@ export function applyWebSearchTool( ): void { ctx.systemPrompt.section({ name: 'tool:web_search', - order: 110, + order: FIRST_PARTY_SECTION_ORDER.TOOL_WEB_SEARCH, text: fetchEnabled ? `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.` : `Use the web_search tool to discover current information on the web. The required queries array accepts 1–${maxQueries} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.`, diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index 3e3e1b5943..cdce829ef6 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -13,8 +13,7 @@ import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' -// Declaration merge only: makes ctx.systemPrompt visible for section registration. -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' export const name = 'tool-ralph' export const inject = ['tools', 'workflowEngine', 'subagents', 'systemPrompt'] @@ -406,7 +405,7 @@ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:ralph', - order: 116, + order: FIRST_PARTY_SECTION_ORDER.TOOL_RALPH, text: 'Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.', }) ctx.tools.register(defineTool({ diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 7128fe4a95..1bdf8103f1 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -23,8 +23,7 @@ import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, ToolWorkflowRunEndData, ToolWorkflowRunStartData, } from './types.ts' -// Declaration merge only: makes ctx.systemPrompt visible for the section registration. -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -211,7 +210,7 @@ export function apply(ctx: Context, config: Config): void { // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ name: `tool:${toolName}`, - order: 115.5, + order: FIRST_PARTY_SECTION_ORDER.TOOL_WORKFLOW, text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`, }) ctx.tools.register(defineTool({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e75d16a06..85576e30a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8067,6 +8067,9 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools diff --git a/snapshots/sdk/subagent-continuable-inheritance/system-prompt.1.expected.md b/snapshots/sdk/subagent-continuable-inheritance/system-prompt.1.expected.md index b198b48a12..c4dc7b6a5e 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/system-prompt.1.expected.md +++ b/snapshots/sdk/subagent-continuable-inheritance/system-prompt.1.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/sdk/subagent-list-agents/system-prompt.1.expected.md b/snapshots/sdk/subagent-list-agents/system-prompt.1.expected.md index b198b48a12..c4dc7b6a5e 100644 --- a/snapshots/sdk/subagent-list-agents/system-prompt.1.expected.md +++ b/snapshots/sdk/subagent-list-agents/system-prompt.1.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/sdk/subagent-report/system-prompt.1.expected.md b/snapshots/sdk/subagent-report/system-prompt.1.expected.md index b198b48a12..c4dc7b6a5e 100644 --- a/snapshots/sdk/subagent-report/system-prompt.1.expected.md +++ b/snapshots/sdk/subagent-report/system-prompt.1.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/sdk/text-turn/system-prompt.expected.md b/snapshots/sdk/text-turn/system-prompt.expected.md index b70fd4112d..55f6c829b6 100644 --- a/snapshots/sdk/text-turn/system-prompt.expected.md +++ b/snapshots/sdk/text-turn/system-prompt.expected.md @@ -2,6 +2,8 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -12,8 +14,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/agent-instructions/system-prompt.expected.md b/snapshots/session/agent-instructions/system-prompt.expected.md index 7150bf2e6b..676b6532e3 100644 --- a/snapshots/session/agent-instructions/system-prompt.expected.md +++ b/snapshots/session/agent-instructions/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index b667c8dd6b..14d8892876 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/code-mode-read-image/system-prompt.expected.md index c9bad7d1fa..9593322100 100644 --- a/snapshots/session/code-mode-read-image/system-prompt.expected.md +++ b/snapshots/session/code-mode-read-image/system-prompt.expected.md @@ -7,6 +7,8 @@ Verify your work by running the code or tests. Keep answers brief and factual. `run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -17,8 +19,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/code-mode-turn/system-prompt.expected.md index 7506ad8373..65908698b1 100644 --- a/snapshots/session/code-mode-turn/system-prompt.expected.md +++ b/snapshots/session/code-mode-turn/system-prompt.expected.md @@ -7,6 +7,8 @@ Verify your work by running the code or tests. Keep answers brief and factual. `run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -17,8 +19,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index b880f67453..db0ad128a8 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/fs-glob-sampling/system-prompt.expected.md b/snapshots/session/fs-glob-sampling/system-prompt.expected.md index 9b4698844c..8968f8848a 100644 --- a/snapshots/session/fs-glob-sampling/system-prompt.expected.md +++ b/snapshots/session/fs-glob-sampling/system-prompt.expected.md @@ -2,6 +2,8 @@ You are an AI agent powered by DeepSeek Harness. You are a concise snapshot agent working in {{cwd}}. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -12,8 +14,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. diff --git a/snapshots/session/lsp-definition/system-prompt.expected.md b/snapshots/session/lsp-definition/system-prompt.expected.md index b906b6f3c8..9a0ab0454b 100644 --- a/snapshots/session/lsp-definition/system-prompt.expected.md +++ b/snapshots/session/lsp-definition/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/product-subagent-codex/system-prompt.expected.md b/snapshots/session/product-subagent-codex/system-prompt.expected.md index 545e903230..b11fb21674 100644 --- a/snapshots/session/product-subagent-codex/system-prompt.expected.md +++ b/snapshots/session/product-subagent-codex/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/pty-tools-sandbox-backend/system-prompt.expected.md b/snapshots/session/pty-tools-sandbox-backend/system-prompt.expected.md index 06b614520c..bf79266c91 100644 --- a/snapshots/session/pty-tools-sandbox-backend/system-prompt.expected.md +++ b/snapshots/session/pty-tools-sandbox-backend/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer shell/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited. diff --git a/snapshots/session/ralph-loop/system-prompt.1.expected.md b/snapshots/session/ralph-loop/system-prompt.1.expected.md index f9eb9268c2..61b1e078d3 100644 --- a/snapshots/session/ralph-loop/system-prompt.1.expected.md +++ b/snapshots/session/ralph-loop/system-prompt.1.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/ralph-loop/system-prompt.2.expected.md b/snapshots/session/ralph-loop/system-prompt.2.expected.md index f9eb9268c2..61b1e078d3 100644 --- a/snapshots/session/ralph-loop/system-prompt.2.expected.md +++ b/snapshots/session/ralph-loop/system-prompt.2.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/read-image/system-prompt.expected.md b/snapshots/session/read-image/system-prompt.expected.md index a0d3386eaa..039caca80e 100644 --- a/snapshots/session/read-image/system-prompt.expected.md +++ b/snapshots/session/read-image/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Yo Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/session-query-spill/system-prompt.expected.md b/snapshots/session/session-query-spill/system-prompt.expected.md index 800356dccc..75f25bd445 100644 --- a/snapshots/session/session-query-spill/system-prompt.expected.md +++ b/snapshots/session/session-query-spill/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/text-turn/system-prompt.expected.md b/snapshots/session/text-turn/system-prompt.expected.md index 975b5a7baf..cc567a5291 100644 --- a/snapshots/session/text-turn/system-prompt.expected.md +++ b/snapshots/session/text-turn/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/session/web-fetch/system-prompt.expected.md b/snapshots/session/web-fetch/system-prompt.expected.md index b70cc036d4..14009ee35f 100644 --- a/snapshots/session/web-fetch/system-prompt.expected.md +++ b/snapshots/session/web-fetch/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-pro model. Your working di Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content. From 9c931ef5a8887b6cdb7bd87645064111cdaab055 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 25 Aug 2026 11:06:17 +0800 Subject: [PATCH 21/37] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E5=9B=BE=E7=89=87=E6=B5=8B=E8=AF=95=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...splay.snapshot.ts => trajectory-image-display.expected.e2e.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/web/tests/{trajectory-image-display.snapshot.ts => trajectory-image-display.expected.e2e.ts} (100%) diff --git a/apps/web/tests/trajectory-image-display.snapshot.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts similarity index 100% rename from apps/web/tests/trajectory-image-display.snapshot.ts rename to apps/web/tests/trajectory-image-display.expected.e2e.ts From 55eeaf6565704b371a3adc99983b97890523bce0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:12:21 +0800 Subject: [PATCH 22/37] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 452f44877a..de8ecb13f6 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b -module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe +module-graph.md: e2827ade8bb65421cd246928c02722ba5035ac9a +module-graph.zh.md: 8babd2669b8103b323e72d338e8ef4c32b5e2dbf diff --git a/docs/module-graph.md b/docs/module-graph.md index 985636d388..e2827ade8b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1060,6 +1060,7 @@ flowchart TD pkg_subagent --> pkg_session_projection pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_session_query + pkg_subagent --> pkg_system_prompt pkg_subagent --> pkg_tools pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants @@ -1824,7 +1825,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d352643e21..8babd2669b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1062,6 +1062,7 @@ flowchart TD pkg_subagent --> pkg_session_projection pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_session_query + pkg_subagent --> pkg_system_prompt pkg_subagent --> pkg_tools pkg_subagent --> pkg_user_approval pkg_session_query_sqlite --> pkg_invariants @@ -1826,7 +1827,7 @@ flowchart TD | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | From 5b3bfbed42a63b88f90aab391733c85dba1a124e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:24:25 +0800 Subject: [PATCH 23/37] test(snapshot): refresh prompt order pins --- snapshots/sdk/bash-tool/system-prompt.expected.md | 4 ++-- .../sdk/subagent-continuable/system-prompt.1.expected.md | 4 ++-- snapshots/web/cordis-tool-round/system-prompt.expected.md | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/snapshots/sdk/bash-tool/system-prompt.expected.md b/snapshots/sdk/bash-tool/system-prompt.expected.md index b70fd4112d..55f6c829b6 100644 --- a/snapshots/sdk/bash-tool/system-prompt.expected.md +++ b/snapshots/sdk/bash-tool/system-prompt.expected.md @@ -2,6 +2,8 @@ You are an AI agent powered by DeepSeek Harness. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -12,8 +14,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/sdk/subagent-continuable/system-prompt.1.expected.md b/snapshots/sdk/subagent-continuable/system-prompt.1.expected.md index b198b48a12..c4dc7b6a5e 100644 --- a/snapshots/sdk/subagent-continuable/system-prompt.1.expected.md +++ b/snapshots/sdk/subagent-continuable/system-prompt.1.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -15,8 +17,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index fa8f816187..5fb5ab7672 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -8,6 +8,8 @@ You are a coding agent powered by the deepseek-v4-flash model. Your working dire Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -18,8 +20,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. @@ -136,8 +136,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. -Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. From 0f7b28ad316ad81b9a2910f441ec43816baeab9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 11:33:21 +0800 Subject: [PATCH 24/37] test(snapshot): refresh web prompt pins --- snapshots/web/code-mode-round/system-prompt.expected.md | 8 ++++---- snapshots/web/fresh-round-trip/system-prompt.expected.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/snapshots/web/code-mode-round/system-prompt.expected.md b/snapshots/web/code-mode-round/system-prompt.expected.md index 6758a52e72..c63a13b0bc 100644 --- a/snapshots/web/code-mode-round/system-prompt.expected.md +++ b/snapshots/web/code-mode-round/system-prompt.expected.md @@ -10,6 +10,8 @@ You are a coding agent powered by the deepseek-v4-flash model. Your working dire Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -20,8 +22,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. @@ -32,10 +32,10 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. -Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code `run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index 004b2dc501..2c16a950e8 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -8,6 +8,8 @@ You are a coding agent powered by the deepseek-v4-flash model. Your working dire Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. @@ -18,8 +20,6 @@ Use the glob tool — not shell find — to discover files by path pattern. A pa Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. @@ -30,8 +30,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. -Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. - Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. +Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. From 8020f6386db88352a84f21389128cd9eca5cfe24 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 12:05:38 +0800 Subject: [PATCH 25/37] docs(system-prompt): sync first-party order references --- ...6-07-30-source-checkout-workdir-distinction.i18n.yaml | 4 ++-- .../2026-07-30-source-checkout-workdir-distinction.md | 2 +- .../2026-07-30-source-checkout-workdir-distinction.zh.md | 2 +- ...026-07-22-plan-specific-collaboration-state.i18n.yaml | 4 ++-- .../2026-07-22-plan-specific-collaboration-state.md | 4 ++-- .../2026-07-22-plan-specific-collaboration-state.zh.md | 4 ++-- docs/subsystems/plan.i18n.yaml | 4 ++-- docs/subsystems/plan.md | 2 +- docs/subsystems/plan.zh.md | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 ++-- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/core/system-prompt/src/index.ts | 4 ++-- packages/core/tools/src/index.ts | 9 ++++----- packages/lsp/tool-lsp/README.i18n.yaml | 4 ++-- packages/lsp/tool-lsp/README.md | 2 +- packages/lsp/tool-lsp/README.zh.md | 2 +- packages/plan/plan-mode/README.i18n.yaml | 4 ++-- packages/plan/plan-mode/README.md | 4 ++-- packages/plan/plan-mode/README.zh.md | 4 ++-- packages/shell/tool-bash/README.i18n.yaml | 4 ++-- packages/shell/tool-bash/README.md | 2 +- packages/shell/tool-bash/README.zh.md | 2 +- packages/shell/tool-pwsh/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- 26 files changed, 42 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml index 527471cd2e..7829446401 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.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/bug-fix/2026-07-30-source-checkout-workdir-distinction.md -2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd -2026-07-30-source-checkout-workdir-distinction.zh.md: 06b09f82f294a70795090a9aec1b2220bd6e7512 +2026-07-30-source-checkout-workdir-distinction.md: 407d11bd2d0bb27bf1953f1a3a84848480e03328 +2026-07-30-source-checkout-workdir-distinction.zh.md: 91251aa040226d72cf431101aa0d064ba107823e diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md index ba6d9dd12b..407d11bd2d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md @@ -14,7 +14,7 @@ A blanket statement that the checkout is not the working directory would also be The section identifies the path as the “DeepSeek Harness implementation checkout.” It says that the checkout location and current working directory are separate values that may differ, forbids inferring the working directory from the checkout path, directs the model to use `pwd`, and limits the checkout's purpose to inspecting or extending DSH itself. -The path derivation, global `harness:source` ownership, and `-99` ordering remain unchanged. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`. +The path derivation and global `harness:source` ownership remain unchanged. The section uses first-party order −900, immediately after `harness:identity`. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md index 06b09f82f2..91251aa040 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.zh.md @@ -14,7 +14,7 @@ Status: implemented 该提示词段将路径标识为「DeepSeek Harness implementation checkout」。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。 -路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。 +路径推导方式与全局 `harness:source` 所有权保持不变。该段使用 first-party 顺序 −900,紧随 `harness:identity`。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。 ## 验证 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index 363165af8f..8c085a2343 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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/simplification/2026-07-22-plan-specific-collaboration-state.md -2026-07-22-plan-specific-collaboration-state.md: c2b882bd6f686662a2369d1b76d6e1611caee9b9 -2026-07-22-plan-specific-collaboration-state.zh.md: 1fdb4b260853be7480f1575d6ded86d036b7d746 +2026-07-22-plan-specific-collaboration-state.md: 20c363d00cfe2bcc2f01af101370f5214c948361 +2026-07-22-plan-specific-collaboration-state.zh.md: 63157b1959236e728e94a38d7ca6397a975595ef diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index c2b882bd6f..20c363d00c 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -26,7 +26,7 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei `plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly at initial or continuation pre-step, or on a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. -The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. +The active state contributes the deployment's section at first-party prompt order 500. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. ### Reviewed exit @@ -68,4 +68,4 @@ The tool renders the submitted plan as a generic card titled by its first headin The implementation has one vocabulary for one shipped feature. Adding another collaboration stance is an explicit design decision instead of a config entry, and automation clients do not acquire human mode controls through ACP. The migration intentionally rejects old `mode/set` logs and old `modes.plan.section` configuration under the repository's pre-release format policy. -Plan state remains reconstructable and tool schemas remain stable, but an idle pending selection is lost if the process exits before the next boundary. Entering or leaving plan mode changes the prompt from order 50 onward, and a model that ignores the guidance can still mutate unless the deployment independently configures sandbox, approval, or filesystem policy. +Plan state remains reconstructable and tool schemas remain stable, but an idle pending selection is lost if the process exits before the next boundary. Entering or leaving plan mode changes the prompt from first-party order 500 onward, and a model that ignores the guidance can still mutate unless the deployment independently configures sandbox, approval, or filesystem policy. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 1fdb4b2608..63157b1959 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -26,7 +26,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` `plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩(compaction)都能恢复该状态,无需实时镜像。spawn 出的 agent(智能体)初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在初始或续步 pre-step 时,或在请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 -激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 +激活状态在 first-party 提示词顺序 500 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 ### 经评审的退出 @@ -68,4 +68,4 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 该实现只用一套词汇描述一项已交付功能。若要添加另一种协作方式,必须显式作出设计决策,而不能只增加配置项;自动化客户端不会通过 ACP 获得面向人类的模式控制。根据仓库的预发布格式策略,本次迁移有意拒绝旧的 `mode/set` 日志与 `modes.plan.section` 配置。 -Plan 状态仍可重建,工具 schema 仍保持稳定,但如果进程在下一边界前退出,空闲状态下待生效的选择会丢失。进入或离开 plan mode 会改变提示词顺序 50 处及其后的内容;如果模型忽略引导,仍可能执行修改,除非部署另行配置沙箱、审批或文件系统策略。 +Plan 状态仍可重建,工具 schema 仍保持稳定,但如果进程在下一边界前退出,空闲状态下待生效的选择会丢失。进入或离开 plan mode 会改变 first-party 提示词顺序 500 处及其后的内容;如果模型忽略引导,仍可能执行修改,除非部署另行配置沙箱、审批或文件系统策略。 diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index 339cd581de..9a36edd323 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.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/plan.md -plan.md: a4332ba97667f125b0996f305e7e2a2a36005ddf -plan.zh.md: e8536d873e92f9afa0482a1268a66e5379f65453 +plan.md: a1e29982507940f58d458e48789dc3259bee3a2e +plan.zh.md: 8df3f5f48ba2813b8c35e362324a71eb8cad763b diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index a4332ba976..a1e2998250 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -26,7 +26,7 @@ interface PlanModeConfig { } ``` -A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than being ignored. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at order 50; inactive plan mode contributes no text. +A missing, blank, or non-string `section` and any unknown key fail at plugin load rather than being ignored. While plan mode is active, the exact `section` text renders as the `plan:policy` [system-prompt section](system-prompt.md) at first-party order 500; inactive plan mode contributes no text. ## The exit tool and the `/plan` command diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index e8536d873e..8df3f5f48b 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -26,7 +26,7 @@ interface PlanModeConfig { } ``` -`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是被忽略。计划模式激活期间,确切的 `section` 文本以 order 50 渲染为 `plan:policy` [系统提示词段落](system-prompt.zh.md);未激活的计划模式不贡献任何文本。 +`section` 缺失、为空白或不是字符串,以及任何未知键,都会在插件加载时失败,而不是被忽略。计划模式激活期间,确切的 `section` 文本以 first-party 顺序 500 渲染为 `plan:policy` [系统提示词段落](system-prompt.zh.md);未激活的计划模式不贡献任何文本。 ## 退出工具与 `/plan` 命令 diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index d5d0aaf0a9..d22a500223 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: 4092cf4fd2985027f3c7e59909f58a5dc1ef4244 -README.zh.md: 5c0f3a109b8d5261ab2a5e2ae0219cb06d493c04 +README.md: c428bbd352f7f58fe4b76cd078e2c1a1993b422a +README.zh.md: 0d5a33fe356d749fa619ad980ab9ee13ed9ba3fc diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 4092cf4fd2..c428bbd352 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -16,7 +16,7 @@ Web uses the shared bounded normal default of five eligible retries after the in #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order −800) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. #### Token effect diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 5c0f3a109b..0d5a33fe35 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -16,7 +16,7 @@ Web 使用共享的有界 normal 默认值,在首次请求后最多再重试 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 −800)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 #### Token 影响 diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 4ac0010df9..241ae986a3 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -122,8 +122,8 @@ export interface PromptAssembly { /** * Sparse integer placements for repository-owned prompt sections. * - * Adjacent values differ by at least ten so a new first-party section can be - * inserted without renumbering the surrounding sequence. + * Adjacent values differ by at least ten to keep the first-party groups sparse + * and make accidental collisions mechanically detectable. * External plugins may use any finite order; equal orders are deterministic by * section name. */ diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index b51579e8ee..7c6aaf146d 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -843,11 +843,10 @@ export class ToolRuntime extends Service { * * Every tool contributes its own guidance section naming its tool, none of * them qualify how that tool is reached, and they all render before the SDK. - * Without this the model - * reads a catalog of tools it is told to use and no statement that only - * `run_code` may be called, so it emits a native call, receives - * `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes the - * deployment is inconsistent. {@link COLLAPSE_SECTION_ORDER} places the rule + * Without this the model reads a catalog of tools it is told to use and no + * statement that only `run_code` may be called, so it emits a native call, + * receives `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes + * the deployment is inconsistent. {@link COLLAPSE_SECTION_ORDER} places the rule * before that guidance rather than after it. * * `both` renders empty: native calls do execute there, so the rule is false. diff --git a/packages/lsp/tool-lsp/README.i18n.yaml b/packages/lsp/tool-lsp/README.i18n.yaml index 8287d4b40b..06baa9f586 100644 --- a/packages/lsp/tool-lsp/README.i18n.yaml +++ b/packages/lsp/tool-lsp/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/lsp/tool-lsp/README.md -README.md: e0fd5738cf114314a8483125d20142d5f1ffac40 -README.zh.md: 71d6a4b5479b2da187b78a388d617afe7214e78c +README.md: 84ca94863758c2ee5bb23a458053cb5d4fefb777 +README.zh.md: 48eacc553337a852c4722931761590d84f44554d diff --git a/packages/lsp/tool-lsp/README.md b/packages/lsp/tool-lsp/README.md index e0fd5738cf..84ca948637 100644 --- a/packages/lsp/tool-lsp/README.md +++ b/packages/lsp/tool-lsp/README.md @@ -26,7 +26,7 @@ The tool requires the workspace root from the session `header.cwd`, with no fall #### What the model sees -One system-prompt section (order 112) positions LSP as a precision aid with the following text: +One system-prompt section (first-party order 2200) positions LSP as a precision aid with the following text: ##### Verbatim guidance diff --git a/packages/lsp/tool-lsp/README.zh.md b/packages/lsp/tool-lsp/README.zh.md index 71d6a4b547..48eacc5533 100644 --- a/packages/lsp/tool-lsp/README.zh.md +++ b/packages/lsp/tool-lsp/README.zh.md @@ -26,7 +26,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) #### 模型看到的内容 -一个系统提示词区段(顺序 112)将 LSP 定位为精确辅助工具,文本如下: +一个系统提示词区段(first-party 顺序 2200)将 LSP 定位为精确辅助工具,文本如下: ##### 逐字指引 diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index b1c7671e8b..dbf5cd9dba 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e -README.zh.md: b3548da880af5d0cacfebba4486294d9d5085626 +README.md: cc518d9854609362ad5e932dc1e819e9757ee5d6 +README.zh.md: cd17f216e914bbd37251cda22a1068e2b0c0ac9c diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 3eabe2cb3f..cc518d9854 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -45,7 +45,7 @@ Design: [plan-specific collaboration state](../../../.agents/notes/implemented/s #### What the model sees -While plan mode is active, the model sees the deployment's exact `section` text at prompt order 50; inactive mode contributes no text. +While plan mode is active, the model sees the deployment's exact `section` text at first-party prompt order 500; inactive mode contributes no text. ##### Configuration example @@ -59,7 +59,7 @@ Inactive mode adds no tokens; active mode adds the configured section to every r #### KV Cache effect -The section is stable within plan mode, but entering or leaving changes the system prompt from order 50 onward. +The section is stable within plan mode, but entering or leaving changes the system prompt from first-party order 500 onward. ### Human command diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index b3548da880..cd17f216e9 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -47,7 +47,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接 #### 模型所见内容 -Plan mode 激活时,模型会在提示词顺序 50 处看到部署方提供的原样 `section` 文本;未激活 mode 不贡献文本。 +Plan mode 激活时,模型会在 first-party 提示词顺序 500 处看到部署方提供的原样 `section` 文本;未激活 mode 不贡献文本。 ##### 配置示例 @@ -61,7 +61,7 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### KV Cache 影响 -该段在 plan mode 内稳定,但进入或退出会从顺序 50 开始改变系统提示词。 +该段在 plan mode 内稳定,但进入或退出会从 first-party 顺序 500 开始改变系统提示词。 ### 人类命令 diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index f3acda2aca..1341473b05 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/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/shell/tool-bash/README.md -README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6 -README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6 +README.md: c684508ea50b80ff6f98b25cd4cd7ca0fd6012a5 +README.zh.md: 3459ed64964bf40b3b72530d7614ca27e45cafc6 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 21749bf0a1..c684508ea5 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -8,7 +8,7 @@ Requires a loaded executor Service Provider (e.g. `@deepseek-ai/dsh-bash-local`) The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering and background-process adaptation remain package-internal. -The plugin also contributes the `tool:bash` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. +The plugin also contributes the `tool:bash` prompt section (first-party order 1000): check the `[exit code: N]` marker on every result and investigate failures before moving on. ## Tools diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 860db1a4c3..3459ed6496 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -8,7 +8,7 @@ 包根只公开 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染和后台进程适配仍保留在包内部。 -插件还会提供 `tool:bash` 提示词段落(顺序 105):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 +插件还会提供 `tool:bash` 提示词段落(first-party 顺序 1000):检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。 ## 工具 diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index c5dcb85005..3978f50f7a 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/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/shell/tool-pwsh/README.md -README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 -README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1 +README.md: 70827f42566f670ffedb37b20111ec13aab09582 +README.zh.md: eec0a9782a2afdd610a09a6851107f94dc6eecc4 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e862fcf0ca..70827f4256 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -8,7 +8,7 @@ Requires a loaded executor implementation and the `shell-env` plugin; the tool s The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-job adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. -The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. +The plugin also contributes the `tool:pwsh` prompt section (first-party order 1010): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. ## Tools diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 0a66b119ac..eec0a9782a 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -8,7 +8,7 @@ 包根只导出 Cordis 插件约定(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 -插件还贡献 `tool:pwsh` 提示词段落(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 +插件还贡献 `tool:pwsh` 提示词段落(first-party 顺序 1010):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 ## 工具 From d35459e3c19dfbc31fd2b0499f726b60d56ca16b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:36:47 +0800 Subject: [PATCH 26/37] refactor(sdk): remove unused root tool filter --- ...3-python-sdk-dsh-profile-runtime.i18n.yaml | 4 +- ...26-08-23-python-sdk-dsh-profile-runtime.md | 2 +- ...08-23-python-sdk-dsh-profile-runtime.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 7 --- docs/config-catalog.zh.md | 7 --- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 4 +- packages/sdk/server/README.zh.md | 4 +- packages/sdk/server/src/index.ts | 13 ----- packages/sdk/server/src/server.ts | 7 --- .../sdk/server/tests/plugin-apply.spec.ts | 48 ------------------- packages/sdk/server/tests/server.spec.ts | 41 ---------------- 13 files changed, 12 insertions(+), 135 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index add950844a..e02c19ea8c 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: dcb4f77048d516f0e187b611dc09c224fba47b58 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 505b49506ae4c4be4809d588448b2c593d3ebaf5 +2026-08-23-python-sdk-dsh-profile-runtime.md: 4af7812db6818b65c754a43ec1a7f973d1cbcbf9 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 155e7d2ae0b0ba3e4163dd85a31de90bef9d588a diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index dcb4f77048..4af7812db6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -24,7 +24,7 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile ...` manages external dependencies and bundle order, `$DSH_HOME/profiles//cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A selected profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. -The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition rather than a server filter; dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. +The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition; the shared JSON-RPC server exposes no root-agent tool filter. Dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 505b49506a..155e7d2ae0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -24,7 +24,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles//cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。所选 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 -[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在,而不是通过 server 筛选;动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 +[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在;共享 JSON-RPC server 不暴露根 agent 工具筛选器。动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cec89ac365..906ce63c6e 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: 19f292ee40861ffa6f8e7fdef4d8717189a2149c -config-catalog.zh.md: 96f9721cae20a1a0ed172349c0189bc47576c75d +config-catalog.md: 54cf64bdf6ff5c8ee51b6ecedaf218267778d19f +config-catalog.zh.md: e887edf989d279435ae04beb40172341f5125b1e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 19f292ee40..54cf64bdf6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1714,13 +1714,6 @@ Requires: `agents` export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 96f9721cae..e887edf989 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1716,13 +1716,6 @@ export interface Config { export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 957bd99306..b44e84412e 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: e6d36df7e387e895941c082226ac955c69f16ca4 -README.zh.md: 13c026edc491ebdbec298b8c9cd330b03d675db7 +README.md: f10c45e2df06383d6726f249bb6db2015a7fb418 +README.zh.md: 7bf3bcdab2a318fbfcba7b5a6c3a60ff883f47b6 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index e6d36df7e3..f10c45e2df 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. The profile composition owns each root agent's tools. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed. +For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 13c026edc4..7bf3bcdab2 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。Profile 组合负责每个根 agent 的工具。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。 +对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 17e9d3a89f..963b4fb3bd 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -25,13 +25,6 @@ export const inject = ['agents'] export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -42,11 +35,6 @@ export interface JsonRpcConfig { export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), - // Preserve omission; Schemastery's materialized empty object is not a valid restriction. - toolFilter: Schema.object({ - allow: Schema.array(Schema.string()).default(undefined as unknown as string[]), - deny: Schema.array(Schema.string()).default(undefined as unknown as string[]), - }).default(undefined as unknown as { allow: string[]; deny: string[] }), }) /** @@ -71,7 +59,6 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, - ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter }, }) // Share one exit task so racing shutdown requests cannot dispose the root or diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 14b996f217..f07b238755 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -15,7 +15,6 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' -import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, @@ -61,8 +60,6 @@ function subagentParentOf(carrier: Scoped): Agent { export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean - /** Restrict each SDK-created root agent to an explicit subset of global tools. */ - toolFilter?: ToolRestriction } function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' { @@ -256,7 +253,6 @@ export class HarnessSdkJsonRpcServer { // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). - const toolFilter = this.options.toolFilter const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, @@ -265,9 +261,6 @@ export class HarnessSdkJsonRpcServer { model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, - ...toolFilter === undefined - ? {} - : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } }, }) const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 41954007eb..c5954e8dac 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,7 +11,6 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' import * as jsonrpc from '../src/index.ts' /** @@ -72,7 +71,6 @@ async function mountPlugin( writeDelayMs?: number failFlush?: boolean beforeServer?: (ctx: Context) => Promise | void - toolFilter?: jsonrpc.JsonRpcConfig['toolFilter'] } = {}, ): Promise { const ctx = new Context() @@ -122,7 +120,6 @@ async function mountPlugin( input, output, exit, - ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter }, }) const frames = (): Record[] => @@ -289,51 +286,6 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) - it('applies the configured root-agent tool filter through the Loader plugin', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-')) - const llmServer = await mockCompletionServer() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) - const harness = await mountPlugin(storageDir, { - toolFilter: { allow: ['kept'] }, - beforeServer: (ctx) => { - for (const name of ['kept', 'excluded']) { - ctx.tools.register(defineTool({ - name, - description: name, - parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - execute: async () => name, - })) - } - }, - }) - try { - harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } }) - await harness.waitForFrame(frame => frame.id === 1, 'initialize response') - harness.send({ - jsonrpc: '2.0', - id: 2, - method: 'session/prompt', - params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, - }) - await harness.waitForFrame( - frame => frame.method === 'session.status' - && (frame.params as { status?: string } | undefined)?.status === 'idle', - 'filtered session idle status', - ) - - const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> } - expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept']) - } finally { - await harness.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 471f794f27..9707417e32 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,7 +14,6 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' -import { defineTool } from '@deepseek-ai/dsh-tools' import { HarnessSdkJsonRpcServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -172,46 +171,6 @@ describe('HarnessSdkJsonRpcServer', () => { } }) - it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-')) - const llmServer = await mockCompletionServer() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) - const ctx = await makeHarness(storageDir) - const tool = (name: string) => defineTool({ - name, - description: name, - parameters: {}, - output: { - schema: { type: 'string' as const }, - render: (_args, value) => [{ type: 'text' as const, text: value }], - }, - execute: async () => name, - }) - ctx.tools.register(tool('kept')) - ctx.tools.register(tool('excluded')) - const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), { - toolFilter: { allow: ['kept'] }, - }) - try { - await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' }) - await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] }) - await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) - ctx.tools.register(tool('future')) - await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] }) - await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) - - expect(llmServer.requests.map((request) => { - const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] - return tools.map(entry => entry.function?.name) - })).toEqual([['kept'], ['kept']]) - await server.shutdown() - } finally { - await ctx.fiber.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn() const mainAgent = ({ From 43f0f07f9ba75b17ef584327edb04f0a0fd72422 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:41:01 +0800 Subject: [PATCH 27/37] refactor(prompt): remove unused complete-persona config --- ...08-24-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- .../2026-08-24-standalone-sdk-minimal-profile.md | 2 +- ...026-08-24-standalone-sdk-minimal-profile.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 10 +++------- docs/config-catalog.zh.md | 10 +++------- packages/core/system-prompt/README.i18n.yaml | 4 ++-- packages/core/system-prompt/README.md | 1 - packages/core/system-prompt/README.zh.md | 1 - packages/core/system-prompt/src/index.ts | 4 ---- .../system-prompt/tests/system-prompt.spec.ts | 15 --------------- .../examples/agent-spine-demo/README.i18n.yaml | 4 ++-- packages/examples/agent-spine-demo/README.md | 4 ++-- packages/examples/agent-spine-demo/README.zh.md | 4 ++-- packages/examples/agent-spine-demo/src/index.ts | 10 +++------- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +----- 16 files changed, 24 insertions(+), 61 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 0d7ca581c3..055485e9b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: 692bf9763f4ad710ff5cc819a7480b2f3e8d9b5f -2026-08-24-standalone-sdk-minimal-profile.zh.md: f39baad1b4429b73f13d601716dadd8376c71bfb +2026-08-24-standalone-sdk-minimal-profile.md: 9cdc92248326f115cb77444531cee420507ff871 +2026-08-24-standalone-sdk-minimal-profile.zh.md: a4fa2b3d4c0941447e84347f4999c15951ad76be diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index 692bf9763f..9cdc922483 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -44,7 +44,7 @@ The bundle test pins the exact row and dependency roster. Profile-template and c ## Alternatives considered -**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also makes the minimal application depend on controls in shared SDK server and system-prompt interfaces. +**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also required root-tool filtering in the shared SDK server and a complete-persona shortcut in the system-prompt config; neither shared interface carries those composition controls. **Restore a Python `cordis` argument or environment-selected complete config.** Rejected because it recreates a Python-owned application composition and bypasses profile plugin management and launcher lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index f39baad1b4..a4fa2b3d4c 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -44,7 +44,7 @@ Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已 ## 考虑过的替代方案 -**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为,还会让极简应用依赖共享 SDK server 与系统提示词接口中的控制项。 +**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为。该方案还要求共享 SDK server 提供根工具筛选,并要求 system-prompt 配置提供 complete-persona 快捷项;这两个共享接口均不再携带这些组合控制项。 **恢复 Python `cordis` 参数或由环境选择的完整配置。** 否决:这会重新创建 Python 自有应用组合,并绕过 profile 插件管理与 launcher 生命周期。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 906ce63c6e..817ef1474e 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: 54cf64bdf6ff5c8ee51b6ecedaf218267778d19f -config-catalog.zh.md: e887edf989d279435ae04beb40172341f5125b1e +config-catalog.md: d4069d17ef269461ca4b76e10b9e7ccdbf3fce21 +config-catalog.zh.md: 1931358d2a9ca54905e4ccadb76b7b770935405f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 54cf64bdf6..d4069d17ef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -166,9 +166,9 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -197,8 +197,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -2450,8 +2448,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e887edf989..1931358d2a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -168,9 +168,9 @@ export type PresetTrust = 'system' | 'user' * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -199,8 +199,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -2452,8 +2450,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index c34ff9547a..dd68b6a139 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/core/system-prompt/README.md -README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 -README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee +README.md: d750a507e628e7609af542227e4528d4d4934ce8 +README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index a52aa3e4c2..d750a507e6 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -11,7 +11,6 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | -| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index cf0ba43aa2..ec5b32d742 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -11,7 +11,6 @@ | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | -| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `''` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 | ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ec36b32432..ffc052e0b9 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -193,8 +193,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names @@ -342,7 +340,6 @@ export class SystemPrompt extends Service { includeHarnessIdentity: z.boolean().default(true), includeRuntimeContext: z.boolean().default(true), persona: z.string().default(''), - personaComplete: z.boolean().default(false), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -369,7 +366,6 @@ export class SystemPrompt extends Service { order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', - complete: config.personaComplete ?? false, }) if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext() } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index c4018103d3..cf196892a7 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -49,21 +49,6 @@ describe('SystemPrompt', () => { expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') }) - it('can make the deployment persona the complete system prompt', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt, { - persona: 'You are a focused SDK agent.', - personaComplete: true, - }) - ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' }) - - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections).toEqual([ - { name: 'deployment:persona', text: 'You are a focused SDK agent.' }, - ]) - expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.') - }) - it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index dedce4969d..fc06579649 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71 -README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55 +README.md: ef82aa4b413be41049dbbd247deee4f1969cdb48 +README.zh.md: 7701da0d965736efd905a3331401752119b160d9 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 28e1496a8c..ef82aa4b41 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index c2838bb4cf..7701da0d96 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a60ebd9e43..5dafbd363a 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -70,9 +70,9 @@ export interface GoalConfig { * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -101,8 +101,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -188,7 +186,6 @@ export function pickSpineConfig(config: Omit): Omit { await ctx.fiber.dispose() }) - it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => { + it('can omit the bundled bash tool, Harness identity, and runtime context', async () => { const ctx = await mount({ includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are a helpful software engineer assistant.', - personaComplete: true, workspaceContext: false, skills: { enabled: false }, toolBash: false, @@ -724,7 +723,6 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.tools.schemas()).toEqual([]) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' }) expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) - ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe('You are a helpful software engineer assistant.') @@ -738,7 +736,6 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are merged.', - personaComplete: true, toolOrder: ['zulu'], tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', @@ -757,7 +754,6 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: appConfig.includeHarnessIdentity, includeRuntimeContext: appConfig.includeRuntimeContext, persona: appConfig.persona, - personaComplete: appConfig.personaComplete, toolOrder: appConfig.toolOrder, tools: appConfig.tools, dshHome: appConfig.dshHome, From d0e8f5f9c463c38d136dadcc039a953c5554e998 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:06:52 +0800 Subject: [PATCH 28/37] test(sdk): leave model surface to packaged snapshot --- ...08-24-standalone-sdk-minimal-profile.i18n.yaml | 4 ++-- .../2026-08-24-standalone-sdk-minimal-profile.md | 2 +- ...026-08-24-standalone-sdk-minimal-profile.zh.md | 2 +- ...nimal-profiles-bare-two-tool-runtime.i18n.yaml | 4 ++-- ...8-11-minimal-profiles-bare-two-tool-runtime.md | 2 +- ...1-minimal-profiles-bare-two-tool-runtime.zh.md | 2 +- apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 15 ++------------- 7 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 055485e9b9..d0fe7a108e 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.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-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: 9cdc92248326f115cb77444531cee420507ff871 -2026-08-24-standalone-sdk-minimal-profile.zh.md: a4fa2b3d4c0941447e84347f4999c15951ad76be +2026-08-24-standalone-sdk-minimal-profile.md: 9c8afbaaf1a8e522af119c1d42ca5ad714eaa879 +2026-08-24-standalone-sdk-minimal-profile.zh.md: b1cd2a348c4b77f197e30a658c13691b2f35d26e diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index 9cdc922483..9c8afbaaf1 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -40,7 +40,7 @@ It also supersedes the minimal-overlay realization in [Python SDK runtime throug ## Verification -The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless Python example test boots the real `dsh --profile sdk-minimal` process and asserts the generated manifest, complete system prompt, and two advertised tools. The installed-wheel minimal scenario exercises persistent shell state, editor effects, JSONL persistence, and the committed model-visible snapshot through the packaged executable. +The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless source test boots the real `dsh --profile sdk-minimal` process, completes a turn, and asserts the generated manifest. The installed-wheel minimal scenario owns the complete system prompt and two advertised tools in its committed model-visible snapshot while exercising persistent shell state, editor effects, and JSONL persistence through the packaged executable. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index a4fa2b3d4c..b1cd2a348c 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -40,7 +40,7 @@ Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已 ## 验证 -组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless Python 示例测试启动真实 `dsh --profile sdk-minimal` 进程,并断言生成的 manifest、完整系统提示词与两个对外公布的工具。Installed-wheel 极简场景通过打包可执行程序验证持久 shell 状态、editor 文件效果、JSONL 持久化与已提交的模型可见快照。 +组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless 源码测试启动真实 `dsh --profile sdk-minimal` 进程、完成一个回合并断言生成的 manifest。Installed-wheel 极简场景通过已提交的模型可见快照固定完整系统提示词和两个对外公布的工具,同时经由打包可执行程序验证持久 shell 状态、editor 文件效果与 JSONL 持久化。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index c24a66064d..575a930f99 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-08-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: d201a7557136822d82950466e39ae0efd27a5888 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: b56f5cea26bb9305ad75a10d153b64f11f841e96 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: ffe8e799e1ba5a71939e3a17d484afd209b4014f +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 587ba696382575244a75f23fe69c14252caf7544 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index d201a75571..ffe8e799e1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -22,7 +22,7 @@ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bund The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index b56f5cea26..587ba69638 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -22,7 +22,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 96cd67172d..a2e2b57829 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -169,15 +169,11 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('boots the standalone minimal profile with its exact model-facing roster', async () => { + it('boots the standalone minimal profile through its generated manifest', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) - const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) + request.resume() request.on('end', () => { - modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') @@ -237,13 +233,6 @@ describe('Python SDK dsh profile keyless smoke', () => { return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) - const request = modelRequests[0] as { - messages?: Array<{ role?: string; content?: unknown }> - tools?: Array<{ function?: { name?: string } }> - } - expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) - const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash' - expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort()) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } From aa801a418af360e9f3c0b1a71722abe46b544e73 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:08:33 +0800 Subject: [PATCH 29/37] test(python): share advanced runtime profile patch --- scripts/smoke-python-runtime.py | 111 ++++++++++---------------------- 1 file changed, 33 insertions(+), 78 deletions(-) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b28e740495..b905c8ac5f 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -221,6 +221,36 @@ def write_profile_patch( return path +def write_advanced_profile_patch(root: Path, name: str, sessions: Path) -> Path: + """Write the shared custom, snapshot, and restart profile patch.""" + return write_profile_patch(root, name, sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) + + def write_mcp_patch(root: Path, sessions: Path, server_script: Path) -> Path: """Write a profile patch that mounts the packaged MCP client.""" return write_profile_patch(root, "mcp.patch.yml", sessions, [{ @@ -923,32 +953,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "custom.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "custom.patch.yml", sessions) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", @@ -1173,32 +1178,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "snapshot.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", @@ -1247,32 +1227,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "restart.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions) first_request = len(MockModelHandler.requests) def run(prompt: str, session_id: str) -> "RunResult": From 8146557ef51b7fe68c3d94d05d5f81e0239633ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:10:14 +0800 Subject: [PATCH 30/37] refactor(python): keep launch override on client --- python/sdk/src/deepseek_harness/client.py | 3 +- python/sdk/tests/test_client.py | 41 ++++++++++------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 804076636d..9b64980cbb 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -34,7 +34,6 @@ class HarnessConfig: initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 - _launch_args: tuple[str, ...] | None = None class HarnessClient: @@ -47,7 +46,7 @@ class HarnessClient: _launch_args: tuple[str, ...] | None = None, ) -> None: self.config = config or HarnessConfig() - self._launch_args = _launch_args or self.config._launch_args + self._launch_args = _launch_args self._proc: subprocess.Popen[str] | None = None self._lock = threading.Lock() self._write_lock = threading.Lock() diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index d5ed7dada8..639f3a0b7d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -471,9 +471,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -611,7 +609,7 @@ for line in sys.stdin: def broken_filter(_notification: object) -> bool: raise RuntimeError("bad notification filter") - with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with ( client.subscribe_notifications(broken_filter) as broken, @@ -648,7 +646,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with pytest.raises(ValueError): client.session_prompt("main", [{"type": "text", "text": "fix it"}]) @@ -675,9 +673,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") request = client.next_request() @@ -709,9 +705,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -730,10 +724,10 @@ time.sleep(60) with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), profile="web", initialize_timeout_seconds=0.1, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: start = time.monotonic() try: @@ -768,9 +762,9 @@ for line in sys.stdin: client = HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), shutdown_timeout_seconds=0.1, - ) + ), + _launch_args=(sys.executable, str(script)), ) client.start() proc = client._proc @@ -808,10 +802,10 @@ Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced") client = HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), env={"QUIESCED_MARKER": str(marker)}, shutdown_timeout_seconds=1, - ) + ), + _launch_args=(sys.executable, str(script)), ) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -838,7 +832,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) + client = HarnessClient(_launch_args=(sys.executable, str(script))) client.start() proc = client._proc assert proc is not None @@ -878,6 +872,7 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__ + assert "_launch_args" not in HarnessConfig.__dataclass_fields__ assert "session_root" not in RunResult.__dataclass_fields__ @@ -900,7 +895,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) + client = HarnessClient(_launch_args=(sys.executable, str(script))) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") client.close() @@ -920,9 +915,9 @@ sys.exit(42) with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), request_timeout_seconds=2, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: with pytest.raises(Exception, match="fatal bridge exploded"): client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -952,9 +947,9 @@ with open(os.environ["SEEN"], "w") as seen: with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), env={"SEEN": str(output)}, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") threads = [ From 7e6193accaee8bb5eaf0335505a0129d4c16ef39 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:16:27 +0800 Subject: [PATCH 31/37] refactor(cli): keep config dumps out of runtime healing --- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/bin.ts | 2 +- apps/cli/src/dump-config.ts | 5 ++--- apps/cli/src/profile-boot.ts | 22 +++++++++++----------- apps/cli/tests/built-bin.e2e.ts | 1 + 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 96e7b4c7b0..8af7762497 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac -README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 +README.md: eb33816b3ac859e8173e62635f74abbe13fb8462 +README.zh.md: ed84927aa4f211926fd32750385268dc5153b3a9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 33de399dc4..eb33816b3a 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -39,7 +39,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0f20245f31..ed84927aa4 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -39,7 +39,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 9e7c600c8d..321849f2d9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -41,7 +41,7 @@ switch (invocation.mode) { } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') - await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index dc2089e7c8..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -26,10 +26,9 @@ const NAME = 'dsh' * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then * never parsed). * @param patches - `--patch` overlay paths, in argv order. - * @returns settlement after the profile is healed and the dump is written. */ -export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise { - const loaded = await prepareProfile(profile, !defaultOnly) +export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { + const loaded = prepareProfile(profile, !defaultOnly) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 30058a380f..674095bca7 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -103,20 +103,19 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b } /** - * Load a resolved profile for `name`: heal the shared module fallback, then - * (re)write the empty root config. The root is always rewritten: the whole - * composition is patch layers, and the vendored Loader's tree write-back (a - * plugin self-disposing persists the current tree) can bake composed rows - * into this file — which would duplicate every bundle insert on the next - * boot. The file exists on disk only because the Loader needs a real include - * root to anchor `baseUrl` at the profile directory (the config dump anchors - * on the same file, so both compose over the identical base). + * Load a resolved profile for `name` and (re)write the empty root config. The + * root is always rewritten: the whole composition is patch layers, and the + * vendored Loader's tree write-back (a plugin self-disposing persists the + * current tree) can bake composed rows into this file — which would duplicate + * every bundle insert on the next boot. The file exists on disk only because + * the Loader needs a real include root to anchor `baseUrl` at the profile + * directory (the config dump anchors on the same file, so both compose over + * the identical base). * @param name - the profile name. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump). * @returns the loaded profile. */ -export async function prepareProfile(name: string, userLayer = true): Promise { - await healProfilesModuleFallback(INSTALL_ANCHOR) +export function prepareProfile(name: string, userLayer = true): Profile { const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) return profile @@ -158,7 +157,8 @@ async function composeProfile( name: string, patchFiles: readonly string[], ): Promise { - const profile = await prepareProfile(name) + await healProfilesModuleFallback(INSTALL_ANCHOR) + const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index eb22fed232..5f9791a050 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -911,6 +911,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('agents: []') expect(stdout).toContain('# == @deepseek-ai/dsh-base') expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") + expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) }, 30_000) it('prints the headless profile without Host or browser layers', async () => { From 9a12505f86c8272ceabc7ea173d5535f0f298b6b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 25 Aug 2026 12:53:48 +0800 Subject: [PATCH 32/37] fix(pty): distinguish pipeline reads from terminal input --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 6 +- .../2026-07-16-persistent-pty-sessions.zh.md | 6 +- .../tests/loader-composition.spec.ts | 6 ++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/process-inspector.ts | 33 +++++++-- .../subprocess-local/src/terminal.ts | 2 +- .../subprocess-local/src/windows-inspector.ts | 2 +- .../tests/process-inspector.spec.ts | 70 +++++++++++++------ .../subprocess-local/tests/terminal.spec.ts | 7 +- .../tests/windows-inspector.spec.ts | 2 +- 13 files changed, 103 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 1bfc08fcc6..3633aa06be 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 3ca35cdd59f38570be478fddf4213335665556a5 -2026-07-16-persistent-pty-sessions.zh.md: a277a97fc73b8e8a901b6524918cdf4f9997461d +2026-07-16-persistent-pty-sessions.md: 44e87390c9a6e4dc97e9466d3113fdd07cd3c500 +2026-07-16-persistent-pty-sessions.zh.md: 0aad7cc409f951c1d36cf543415c6ac1faf143ba diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 3ca35cdd59..44e87390c9 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. +On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting process's `/proc//fd/0` must also resolve to the terminal shell's fd 0 target, so a pipeline reader blocked on its pipe remains a running command rather than terminal-input readiness. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. @@ -157,9 +157,9 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. -- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. +- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` and PTY-consumer tests jointly exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. -- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. +- A Loader-driven `cordis.yml` test mounts the real three-package composition and verifies that delayed pipeline output returns with the completed command instead of being classified as terminal-input readiness. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - Package contracts, the architecture map, subsystem pages, generated catalogs, and the website API describe the same shipped surface. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index a277a97fc7..0aad7cc409 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -74,7 +74,7 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 -在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 +在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待进程的 `/proc//fd/0` 还必须与终端 shell 的 fd 0 解析到同一目标,因此阻塞于管道的流水线读取端仍属于正在运行的命令,不构成终端输入就绪。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 @@ -157,9 +157,9 @@ plugins: ## 验证 - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 -- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 +- 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。 -- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 +- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合,并验证延迟到达的流水线输出随已完成命令返回,而不会被归类为终端输入就绪。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - 包约定、架构图、子系统页面、生成目录和 website API 描述同一个已发布接口。 ## 后果 diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 6d6affdb44..6909d9eea8 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -152,6 +152,12 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { )) expect(heredoc).toBe('alpha\nbeta') + const pipeline = text(await execute( + 'pipeline', + '{ sleep 0.1; printf "delayed\\n"; } | cat', + )) + expect(pipeline).toBe('delayed') + const large = text(await execute('large-output', 'seq 1 12050')) expect(large.startsWith('1\n2\n3\n')).toBe(true) expect(large).toContain('') diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2a77e8192c..229008e146 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: e77aa36d8e6dc4ac999261f3a7d0c21a81ed08ff -README.zh.md: 25f7fed19898c81ececb5308b67e8d8a1140e3af +README.md: 26b23f1e0b9a7efee9a2f20f259c69a832e64d53 +README.zh.md: 797eda99fc131eff93c2eaaf87603588a01b3067 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index e77aa36d8e..26b23f1e0b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Linux reports an exact input wait only when the waiting process's fd 0 resolves to the terminal shell's fd 0 target, so a pipeline reader blocked on `pipe:[…]` cannot publish terminal readiness. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 25f7fed198..797eda99fc 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。Linux 只有在等待进程的 fd 0 与终端 shell 的 fd 0 解析到同一目标时才报告精确输入等待,因此阻塞于 `pipe:[…]` 的流水线读取端无法发布终端就绪状态。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 89effc0082..05b775e5ef 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -1,6 +1,6 @@ /** Platform process-table inspection for terminal readiness, signals, and teardown. */ -import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' +import { closeSync, openSync, readFileSync, readdirSync, readlinkSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' import { createWindowsProcessInspector } from './windows-inspector.ts' @@ -14,7 +14,14 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number): boolean + /** + * Report whether the foreground group waits on the terminal shell's stdin. + * + * @param pgid Foreground process-group identifier. + * @param shellPid Persistent terminal shell process identifier. + * @returns Whether a group member is blocked reading the shell's terminal input. + */ + isStdinWaiting(pgid: number, shellPid: number): boolean /** Return the root and its current transitive descendants, children first. */ processTree(rootPid: number): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ @@ -29,6 +36,7 @@ export interface ProcessInspector { export interface ProcessInspectorInternals { readFile(path: string): string readDir(path: string): string[] + readLink(path: string): string open(path: string): number read(fd: number, buffer: Buffer, length: number, position: number): number close(fd: number): void @@ -40,6 +48,7 @@ export interface ProcessInspectorInternals { const DEFAULT_INTERNALS: ProcessInspectorInternals = { readFile: path => readFileSync(path, 'utf8'), readDir: path => readdirSync(path), + readLink: path => readlinkSync(path, 'utf8'), open: path => openSync(path, 'r'), read: (fd, buffer, length, position) => readSync(fd, buffer, 0, length, position), close: closeSync, @@ -88,6 +97,14 @@ function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcS } } +function readLinuxStdinTarget(internals: ProcessInspectorInternals, pid: number): string | undefined { + try { + return internals.readLink(`/proc/${pid}/fd/0`) + } catch (_unreadableStdinTarget) { + return undefined + } +} + /** * Report whether a Linux process group has an executing member. `false` * means the group contains only zombie/dead entries; `undefined` means the @@ -231,7 +248,7 @@ abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number): boolean + abstract isStdinWaiting(pgid: number, shellPid: number): boolean abstract processTree(rootPid: number): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -284,14 +301,18 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number): boolean { + isStdinWaiting(pgid: number, shellPid: number): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false + const terminalStdinTarget = readLinuxStdinTarget(this.internals, shellPid) + if (terminalStdinTarget === undefined) return false for (const pid of numericEntries(this.internals, '/proc')) { if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { const syscall = readSyscall(this.internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true + if (syscall !== undefined + && syscallWaitsOnStdin(this.internals, pid, syscall, table) + && readLinuxStdinTarget(this.internals, pid) === terminalStdinTarget) return true } } return false @@ -339,7 +360,7 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _shellPid: number): boolean { return false } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 0a51287d24..80782e24e7 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -88,7 +88,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId), + inputWaiting: this.inspector.isStdinWaiting(processGroupId, this.pid), } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 7280cbb9ee..6889a3e65d 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -93,7 +93,7 @@ export class WindowsProcessInspector implements ProcessInspector { return shellPid } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _shellPid: number): boolean { return false } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index aadf2e1388..5115db1233 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -23,6 +23,7 @@ function syscall(number: number, ...args: number[]): string { function fakeInternals() { const files = new Map() const dirs = new Map() + const links = new Map() const memories = new Map() const fds = new Map() const kills: Array<[number, NodeJS.Signals]> = [] @@ -40,6 +41,11 @@ function fakeInternals() { if (value === undefined) throw new Error(`missing ${path}`) return value }, + readLink(path) { + const value = links.get(path) + if (value === undefined) throw new Error(`missing ${path}`) + return value + }, open(path) { if (!memories.has(path)) throw new Error(`missing ${path}`) const fd = nextFd++ @@ -61,7 +67,7 @@ function fakeInternals() { kill(pid, signal) { kills.push([pid, signal]) }, } return { - internals, files, dirs, memories, kills, + internals, files, dirs, links, memories, kills, setPs(value: string) { ps = value }, setTpgid(value: string) { tpgid = value }, } @@ -132,29 +138,48 @@ describe('Linux process inspector', () => { fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) fake.dirs.set('/proc/101/task', ['101', '102']) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + fake.links.set('/proc/101/fd/0', '/dev/pts/1') const inspector = createProcessInspector('linux', 'x64', fake.internals) fake.files.set('/proc/100/task/100/syscall', 'running') fake.files.set('/proc/101/task/101/syscall', '-1 0x0') fake.files.set('/proc/101/task/102/syscall', syscall(0, 0)) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10)) const fdSet = Buffer.alloc(0x11) fdSet[0x10] = 1 fake.memories.set('/proc/101/mem', fdSet) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) const poll = Buffer.alloc(8) poll.writeInt32LE(0, 0) poll.writeInt16LE(1, 4) fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1)) fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll])) - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1)) fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n') - expect(inspector.isStdinWaiting(77)).toBe(true) + expect(inspector.isStdinWaiting(77, 100)).toBe(true) + }) + + it('rejects pipeline reads whose fd 0 is not the terminal input', () => { + const fake = fakeInternals() + fake.dirs.set('/proc', ['100']) + fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) + fake.dirs.set('/proc/100/task', ['100']) + fake.files.set('/proc/100/task/100/syscall', syscall(0, 0)) + fake.links.set('/proc/99/fd/0', '/dev/pts/1') + fake.links.set('/proc/100/fd/0', 'pipe:[123]') + const inspector = createProcessInspector('linux', 'x64', fake.internals) + + expect(inspector.isStdinWaiting(77, 99)).toBe(false) + fake.links.delete('/proc/100/fd/0') + expect(inspector.isStdinWaiting(77, 99)).toBe(false) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + expect(inspector.isStdinWaiting(77, 99)).toBe(true) }) it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { @@ -162,30 +187,31 @@ describe('Linux process inspector', () => { fake.dirs.set('/proc', ['100']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') fake.files.set('/proc/100/task/100/syscall', syscall(0, 2)) - expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77, 100)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(999)) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0') - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.dirs.delete('/proc/100/task') - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) fake.dirs.set('/proc', ['100', '200']) fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2')) - expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false) + expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77, 100)).toBe(false) }) it('contains unreadable syscall, memory, and fdinfo boundaries', () => { @@ -194,19 +220,21 @@ describe('Linux process inspector', () => { fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.dirs.set('/proc/100/task', ['100']) const inspector = createProcessInspector('linux', 'x64', fake.internals) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) + fake.links.set('/proc/100/fd/0', '/dev/pts/1') + expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) const noStdinPoll = Buffer.alloc(0x28) noStdinPoll.writeInt32LE(2, 0x20) noStdinPoll.writeInt16LE(1, 0x24) fake.memories.set('/proc/100/mem', noStdinPoll) fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1)) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 100)).toBe(false) }) }) @@ -217,7 +245,7 @@ describe('macOS process inspector', () => { fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n') const inspector = createProcessInspector('darwin', 'arm64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(55) - expect(inspector.isStdinWaiting(55)).toBe(false) + expect(inspector.isStdinWaiting(55, 10)).toBe(false) expect(inspector.processTree(10)).toEqual([ { pid: 12, started: 'Mon Jul 21 10:00:02 2026' }, { pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index c2aca88085..330660eda3 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -59,12 +59,16 @@ class FakeInspector implements ProcessInspector { readonly alive = new Set() readonly groups: Array<[number, SubprocessTerminalSignal]> = [] readonly processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = [] + readonly stdinChecks: Array<[number, number]> = [] throwGroup = false throwProcess = false removeOnSignal = true foregroundPgid() { return this.pgid } - isStdinWaiting() { return this.waiting } + isStdinWaiting(pgid: number, shellPid: number) { + this.stdinChecks.push([pgid, shellPid]) + return this.waiting + } processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] } processSession() { return this.sessionMembers } isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } @@ -182,6 +186,7 @@ describe('LocalTerminalHandle', () => { await handle.write('input\r') expect(pty.writes).toEqual(['input\r']) expect(await handle.inspectForeground()).toEqual({ processGroupId: 456, inputWaiting: true }) + expect(inspector.stdinChecks).toEqual([[456, 123]]) expect(await handle.signalForeground('SIGINT')).toBe(456) expect(inspector.groups).toEqual([[456, 'SIGINT']]) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index e00bdeb9e2..5950fd9328 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -65,7 +65,7 @@ describe('WindowsProcessInspector (injected internals)', () => { const fake = fakeInternals() const inspector = new WindowsProcessInspector(fake.internals) expect(inspector.foregroundPgid(77)).toBe(77) - expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.isStdinWaiting(77, 10)).toBe(false) expect(inspector.processSession(77)).toEqual([]) }) From 1825cb4657061ffe70d9ebd11c3e3575329fc21b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 25 Aug 2026 12:42:08 +0800 Subject: [PATCH 33/37] feat(client): highlight streaming fences incrementally Keep recognized code fences syntax-highlighted while assistant text streams. Preserve completed Shiki token lines across chunks, mirror token styles and CRLF handling, and retain plain rendering for unsupported or math-like fences. Add unit, DOM-parity, and keyless assembled-Web coverage for the streaming-to-settled transition. Closes #1499 --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 2 +- .../2026-07-23-web-assistant-markdown.zh.md | 2 +- ...20-web-streaming-fence-highlight.i18n.yaml | 6 + ...026-08-20-web-streaming-fence-highlight.md | 37 +++ ...-08-20-web-streaming-fence-highlight.zh.md | 37 +++ .../mid-stream.expected.md | 97 ++++++++ .../tests/streaming-fence-highlight.e2e.ts | 146 +++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 88 ++++++- .../src/markdown/MarkdownText.tsx | 7 +- .../ui-primitives/src/markdown/highlight.ts | 147 ++++++++++- .../ui-primitives/src/markdown/render.tsx | 10 +- .../markdown-dom/code-fences.streaming.txt | 31 ++- .../fence-trailing-blank-lines.streaming.txt | 17 +- .../math-edge-cases.streaming.txt | 1 + .../tests/markdown-dom-parity.client.spec.tsx | 4 + .../tests/markdown.client.spec.tsx | 42 +++- .../streaming-code-block.client.spec.tsx | 229 ++++++++++++++++++ tsconfig.host.json | 1 + 23 files changed, 877 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md create mode 100644 apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md create mode 100644 apps/web/tests/streaming-fence-highlight.e2e.ts create mode 100644 packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx 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/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 
{elements}
+ }, [streaming, trimmed, lang, loaded]) const rootRef = useRef(null) const [copied, setCopied] = useState(false) @@ -39,16 +99,18 @@ export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: Cod }) }, [copied, trimmed]) - const body = html === undefined - ? ( -
{trimmed}
- ) - : ( - // 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 @@
+ #text "ts"