From e2ef25b06e1d423b607cc318987049e24ece22b6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 11:40:33 +0800 Subject: [PATCH 01/43] fix(ci): windows coverage runs zero-build like the linux lane The windows-coverage job built the workspace before running the same ci-coverage gates as Linux, but the instrumented corpus resolves workspace imports to src through the tsconfig paths map and never consumes lib/; the two lib-consuming suites (webworker-packer image-loadable, session-persistence-sqlite built-package) self-skip on unbuilt checkouts, exactly how the Linux lane already runs them. Remove the build step so both lanes behave identically, and pin the zero-build invariant in ci-workflow.spec.ts (red before this change, green after). Agent Notes updated in place: corrected the build-wait rationale and the wrong attribution of the packer assertions to the instrumented suite. --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 6 +++--- .../2026-08-08-native-windows-pull-request-ci.zh.md | 6 +++--- .../2026-08-18-in-job-partitioned-coverage.i18n.yaml | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.md | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.zh.md | 4 ++-- .github/workflows/ci.yml | 7 ++++--- scripts/ci-workflow.spec.ts | 5 +++++ 8 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 24312c61ab..5646fd79f8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: cf656ab2c82fb43b4172a07b6b68fa53a62ef8a3 -2026-08-08-native-windows-pull-request-ci.zh.md: 81ed390d27d7f7918c540a56a2d1fac1c094a447 +2026-08-08-native-windows-pull-request-ci.md: 34df41f0db9f09a60ec20c989df054f0b200fb0b +2026-08-08-native-windows-pull-request-ci.zh.md: 379c5a6200769a80b3d543873ebd172ffaad0b7d diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index cf656ab2c8..34df41f0db 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,9 +18,9 @@ Every pull request also starts four independent native jobs on the organization- `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. -`windows-coverage` completes a workspace build before [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) starts four single-worker instrumented shards beside a two-worker exempt-heavy gate. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The SQLite busy-journal pacing fixture injects two busy results followed by success under the normal busy budget and observes each inter-attempt delay, keeping schema-setup scheduling outside its timing assertion. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. -The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers after the build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. +The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. The first native run exposed two failures hidden by the compatibility lane. Documentation projection tests derived an image basename by splitting only on `/`; they now use Node's platform basename. Chokidar consumers received `%TEMP%` through the `C:\\Users\\RUNNER~1` 8.3 alias while libuv returned the long directory name, tripping its Windows event-path assertion. Shared settings and credentials watchers, plus Cordis module and exact-config HMR, now canonicalize the existing native watch base or deepest existing ancestor before opening the watcher and preserve a missing suffix, while file access and diagnostics retain the configured path. Module HMR attaches listeners and awaits the main watcher's ready event before plugin startup settles, so an immediate post-boot edit cannot race the initial scan. HMR acceptance derives expected identities through the same asynchronous native realpath operation, avoiding a synchronous Windows spelling that can retain the 8.3 alias. @@ -52,6 +52,6 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor Wine preserves the required aggregate's existing critical path and job identity. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes Wine plus the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results. -Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup and repeat builds across the build, coverage, and observational workspaces, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. +Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 81ed390d27..379c5a6200 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,9 +18,9 @@ Status: implemented `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 -`windows-coverage` 会先完成一次工作区构建,再由[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md)启动 4 个单 worker 插桩分片,并与一个双 worker 的豁免重型门禁并行运行。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。SQLite busy-journal 节奏 fixture 会在普通 busy 预算内先注入两次 busy 结果,再返回成功,并观察每次尝试之间的延迟,使 schema 设置的调度时间不进入该断言。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 -16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在构建后调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 +16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 首次原生运行暴露出两项被兼容性通道掩盖的故障。文档投影测试此前只按 `/` 拆分来派生图片 basename;现在改为使用 Node 根据平台计算的 basename。Chokidar 消费方收到的 `%TEMP%` 以 `C:\\Users\\RUNNER~1` 这个 8.3 别名表示,而 libuv 返回的是长目录名,导致其 Windows 事件路径断言失败。共享的设置 watcher 与凭据 watcher,以及 Cordis 的模块 HMR(热模块替换)与精确配置 HMR,现在都会在打开 watcher 前规范化现有的原生监听基准路径或层级最深的现有祖先路径,并保留尚不存在的后缀;文件访问和诊断仍使用配置路径。模块 HMR 会挂接监听器并等待主 watcher 的 ready 事件,之后插件启动才会完成,因此启动后立即发生的编辑无法与初始扫描形成竞态。HMR 验收通过相同的异步原生 realpath 操作派生预期身份,避免同步 Windows 路径写法仍保留 8.3 别名。 @@ -52,6 +52,6 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 Wine 保留必需聚合流程现有的关键路径和作业身份。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用 Wine 加定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。 -尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会重复设置流程,并在构建、覆盖率与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 +尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 619841a551..e79c101026 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 33824f0aa6f3541df8ca2e0cc8c417b40b5d7933 -2026-08-18-in-job-partitioned-coverage.zh.md: c32738462f7e33d9377d314a9ded5f82cebe9db3 +2026-08-18-in-job-partitioned-coverage.md: 2bde2a8ea1144543cac2e44bc30828ae9b52b5b4 +2026-08-18-in-job-partitioned-coverage.zh.md: 95f6ccdeedf1117fe8ea769d7555361b285c368c diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 33824f0aa6..2bde2a8ea1 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. The workspace build and production-site validation start immediately; both coverage gates wait for the complete build. The instrumented suite contains packer assertions over built `lib/` output, so this dependency prevents it from reading a partially emitted package closure, while also preventing the exempt gate's temporary Oxlint probes from racing source compilation. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and the instrumented corpus's built-package check — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -46,6 +46,6 @@ Completed native Windows comparisons measured two partitions near 405 seconds an Coverage pays one Vitest startup/configuration cost per partition and one report-merge cost, but it avoids another workflow topology and keeps one final threshold verdict. Partition output may interleave, while the partition start labels and Vitest file identities retain attribution. -Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Native Windows starts both coverage gates after the complete build because its instrumented corpus can consume built artifacts; Linux's dedicated coverage job does not share a workspace with a concurrent build. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. +Linux and Windows use the same coordinator with platform-specific partition counts and surrounding worker budgets. Both pull-request coverage lanes run without a preceding build; the complete reference (serial-windows standby) still starts its coverage gates after the build gate so the lib-consuming suites execute against real artifacts. Local coverage stays simple unless a caller explicitly chooses the partitioned package script and supplies a valid count greater than one. Future tuning starts from completed runs at one fixed configuration. Slow progress alone never raises partition count or outer concurrency, because repeated restarts would erase the only evidence needed to choose a stable setting. diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index c32738462f..95f6ccdeed 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -18,7 +18,7 @@ Status: implemented 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。工作区构建与生产网站验证会立即启动;两道覆盖率门禁都等待完整构建。插桩套件包含针对已构建 `lib/` 输出的打包器断言,因此这项依赖可避免它读取只完成部分产出的包闭包,也可避免豁免门禁的临时 Oxlint 探针与源码编译竞态。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与插桩语料的 built-package 校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -46,6 +46,6 @@ Status: implemented 每个分区都要支付 1 次 Vitest 启动与配置开销,最后还要执行 1 次报告合并,但它不引入另一套工作流拓扑,并保留唯一的最终阈值判定。分区输出可能交错,但分区启动标签和 Vitest 文件标识仍可用于归因。 -Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。原生 Windows 会在完整构建之后启动两道覆盖率门禁,因为其插桩语料可能消费构建产物;Linux 的专用覆盖率 job 不会与同一工作区中的并发构建共享目录。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 +Linux 与 Windows 使用相同的协调器,并各自设置分区数量与外围 worker 预算。两条拉取请求覆盖率通道都以零构建方式运行;完整参考流程(serial-windows standby)仍在构建门禁之后启动其覆盖率门禁,使消费构建产物的套件对真实产物执行。本地覆盖率默认保持简单;只有调用方显式选择分区包脚本并提供大于 1 的合法数量时,才启用分区。 未来调优从一个固定配置的完整运行开始。进度缓慢本身绝不会提高分区数量或外层并发,因为反复重启会抹掉选择稳定设置所需的唯一证据。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 545142b639..06647fdb04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,9 +512,10 @@ jobs: } else { pnpm install --frozen-lockfile } - - name: Build before coverage - shell: pwsh - run: pnpm run build + # No build before coverage, matching the Linux lane: workspace imports + # resolve to src through the tsconfig paths map, and the lib-consuming + # suites (webworker-packer image-loadable, session-persistence-sqlite + # built-package) self-skip on unbuilt checkouts. - name: Run Windows coverage shell: pwsh run: pnpm run check:ci:coverage diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2b9d7b6874..3d9c0d340d 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -154,6 +154,11 @@ describe('CI workflow', () => { isRecord(step) && typeof step.run === 'string' )) expect(coverageCommands.map(step => step.run)).toContain('pnpm run check:ci:coverage') + // Windows coverage runs zero-build like the Linux lane: workspace imports + // resolve to src through the tsconfig paths map, and the lib-consuming + // suites (webworker-packer image-loadable, session-persistence-sqlite + // built-package) self-skip on unbuilt checkouts. + expect(coverageCommands.map(step => step.run)).not.toContain('pnpm run build') // windows-native-tests runs the Windows-specific specs. expect(windowsNativeTests.name).toBe('windows node 24 / native tests') From 2dd8b3eac3206391070e1f080a876010015613dc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 12:00:05 +0800 Subject: [PATCH 02/43] fix(ci): harden the zero-build coverage assertion and list all skip suites Strengthen the ci-workflow.spec.ts guard to match any 'pnpm run build' spelling (corepack prefix, multi-line run blocks) instead of one exact string, and complete the lib-consuming self-skip inventory with the webworker-runtime transform-corpus import sweep alongside the packer image assertions and the built-package check. Update the ci.yml comment and the partitioned-coverage note (both languages) to match, and drop the stale 'post-build' phase wording and the native-Windows build-wait rationale from the coverage-exempt comment. --- .../2026-08-18-in-job-partitioned-coverage.i18n.yaml | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.md | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.zh.md | 4 ++-- .github/workflows/ci.yml | 5 +++-- scripts/ci-workflow.spec.ts | 9 ++++++--- scripts/coverage-exempt.ts | 5 +++-- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index e79c101026..7ea3e67eaf 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: 2bde2a8ea1144543cac2e44bc30828ae9b52b5b4 -2026-08-18-in-job-partitioned-coverage.zh.md: 95f6ccdeedf1117fe8ea769d7555361b285c368c +2026-08-18-in-job-partitioned-coverage.md: ae6ca04106d4ea11d6fbcce2d3c26917ac08d77d +2026-08-18-in-job-partitioned-coverage.zh.md: d683f7eab4a77a7e3f6205bbd51fadc68922e48d diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index 2bde2a8ea1..ae6ca04106 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and the instrumented corpus's built-package check — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and full-corpus import sweep, and the instrumented corpus's built-package check — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics @@ -30,7 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, weighted longest-processing-time assignment (including a case that fails when assignment ignores recorded weights), the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, both native Windows coverage gates' complete-build dependency, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures. -Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current post-build phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. +Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index 95f6ccdeed..d683f7eab4 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -18,7 +18,7 @@ Status: implemented 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与插桩语料的 built-package 校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与全语料导入 sweep,以及插桩语料的 built-package 校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 @@ -30,7 +30,7 @@ Status: implemented `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、加权最长处理时间分配(含一个在分配忽略记录权重时必然失败的用例)、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、两道原生 Windows 覆盖率门禁对完整构建的依赖、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。 -已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的构建后阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 +已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。 ## 曾考虑的替代方案 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06647fdb04..88653de55a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -514,8 +514,9 @@ jobs: } # No build before coverage, matching the Linux lane: workspace imports # resolve to src through the tsconfig paths map, and the lib-consuming - # suites (webworker-packer image-loadable, session-persistence-sqlite - # built-package) self-skip on unbuilt checkouts. + # suites (webworker-packer image-loadable, webworker-runtime + # transform-corpus, session-persistence-sqlite built-package) self-skip + # on unbuilt checkouts. - name: Run Windows coverage shell: pwsh run: pnpm run check:ci:coverage diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 3d9c0d340d..e0d66f6594 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -156,9 +156,12 @@ describe('CI workflow', () => { expect(coverageCommands.map(step => step.run)).toContain('pnpm run check:ci:coverage') // Windows coverage runs zero-build like the Linux lane: workspace imports // resolve to src through the tsconfig paths map, and the lib-consuming - // suites (webworker-packer image-loadable, session-persistence-sqlite - // built-package) self-skip on unbuilt checkouts. - expect(coverageCommands.map(step => step.run)).not.toContain('pnpm run build') + // suites (webworker-packer image-loadable, webworker-runtime + // transform-corpus, session-persistence-sqlite built-package) self-skip + // on unbuilt checkouts. The regex catches a regression spelled as + // 'corepack pnpm run build' or folded into a multi-line run block, which + // an exact string match would miss. + expect(coverageCommands.every(step => !/\bpnpm\s+run\s+build(?:\s|$)/.test(step.run))).toBe(true) // windows-native-tests runs the Windows-specific specs. expect(windowsNativeTests.name).toBe('windows node 24 / native tests') diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index e7dc90bb29..9e2e406100 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -50,8 +50,9 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, // Built-artifact proof. Packer/runtime src is threshold-excluded, and the - // native Windows aggregate makes this uninstrumented gate wait for build so - // the suite never observes a partially emitted workspace closure. + // suite self-skips on unbuilt checkouts; the serial-windows complete + // reference still starts this uninstrumented gate after its build gate, so + // the assertions execute against complete real artifacts there. { filter: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts', exclude: 'packages/experimental/webworker-packer/tests/image-loadable.spec.ts', From 16853e1f7745cecbadbf55720725c27da75f75b8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 12:03:47 +0800 Subject: [PATCH 03/43] fix(webworker-packer): update image-loadable JSDoc for zero-build coverage Both coverage lanes now run before any build, so the post-build uninstrumented gate wording applies only to the serial-windows complete reference; preview builds still exercise the suite against real artifacts. --- .../webworker-packer/tests/image-loadable.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts index a00fa133d0..e151404717 100644 --- a/packages/experimental/webworker-packer/tests/image-loadable.spec.ts +++ b/packages/experimental/webworker-packer/tests/image-loadable.spec.ts @@ -68,10 +68,10 @@ describe('preview example overlays', () => { }) /** - * The pack consumes built `lib/` output. An unbuilt checkout (the unit - * coverage lane runs before any build) self-skips. Native Windows routes this - * suite through its post-build uninstrumented gate, and preview builds exercise - * the same path against complete real artifacts. + * The pack consumes built `lib/` output. An unbuilt checkout (both coverage + * lanes run before any build) self-skips; the serial-windows complete + * reference routes this suite through its post-build uninstrumented gate, and + * preview builds exercise the same path against complete real artifacts. */ const subjectBuilt = existsSync(join(repoRoot, 'packages/util/timeout/lib/index.js')) From a9bc7b7056aca83f4d5cae2e470b6236a6a98324 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 12:13:43 +0800 Subject: [PATCH 04/43] fix(ci): add client-bundle to the zero-build self-skip inventory client-bundle.client.spec.ts reads packages/client/ui-trajectory/lib/client.js and skips all three cases when the bundle is absent, so it is a fourth lib-consuming suite in the instrumented corpus. List it beside built-package in the ci.yml comment, the ci-workflow.spec.ts comment, and the partitioned-coverage note (both languages). --- .../2026-08-18-in-job-partitioned-coverage.i18n.yaml | 4 ++-- .../process/2026-08-18-in-job-partitioned-coverage.md | 2 +- .../process/2026-08-18-in-job-partitioned-coverage.zh.md | 2 +- .github/workflows/ci.yml | 4 ++-- scripts/ci-workflow.spec.ts | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml index 7ea3e67eaf..70001cf435 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.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/process/2026-08-18-in-job-partitioned-coverage.md -2026-08-18-in-job-partitioned-coverage.md: ae6ca04106d4ea11d6fbcce2d3c26917ac08d77d -2026-08-18-in-job-partitioned-coverage.zh.md: d683f7eab4a77a7e3f6205bbd51fadc68922e48d +2026-08-18-in-job-partitioned-coverage.md: 1493f64bf5a378b201419fe69a9b57a3aaf02410 +2026-08-18-in-job-partitioned-coverage.zh.md: 3dd8435c552e4ee8af153c0d2f462c350c4b0751 diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md index ae6ca04106..1493f64bf5 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md @@ -18,7 +18,7 @@ When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:cove The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory. -`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and full-corpus import sweep, and the instrumented corpus's built-package check — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. +`DSH_COVERAGE_MAX_WORKERS` continues to size the uninstrumented exempt gate and the ordinary non-partitioned path; it does not resize partition children. Native Windows gives the exempt gate two workers and admits four concurrent outer gates. In the complete reference, the workspace build and production-site validation start immediately and both coverage gates wait for the complete build; the wait also keeps the exempt gate's temporary Oxlint probes from racing source compilation. The pull-request coverage job runs the same zero-build coverage as Linux: workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites — the exempt gate's packer image assertions and full-corpus import sweep, and the instrumented corpus's built-package and client-bundle artifact checks — self-skip on unbuilt checkouts. The observational inventory waits only for both coverage gates to settle, so it still runs after a coverage failure; each gate's `needs` dependencies remain pass-required. Linux overlaps four instrumented partition processes with two exempt workers, restoring the ordinary path's former four-way instrumented concurrency while keeping every instrumented process single-worker. ## Failure and output semantics diff --git a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md index d683f7eab4..3dd8435c55 100644 --- a/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md +++ b/.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md @@ -18,7 +18,7 @@ Status: implemented 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。 -`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与全语料导入 sweep,以及插桩语料的 built-package 校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 +`DSH_COVERAGE_MAX_WORKERS` 继续控制无插桩豁免门禁和普通非分区路径的规模,不会调整分区子进程。原生 Windows 为豁免门禁分配 2 个 worker,并允许 4 道外层门禁并发。在完整参考流程中,工作区构建与生产网站验证会立即启动,两道覆盖率门禁都等待完整构建;这次等待也能避免豁免门禁的临时 Oxlint 探针与源码编译竞态。拉取请求覆盖率 job 与 Linux 一样以零构建方式运行:工作区导入通过 tsconfig paths 映射解析到 `src`,而消费构建产物的套件——豁免门禁的打包器镜像断言与全语料导入 sweep,以及插桩语料的 built-package 与 client-bundle 产物校验——在未构建的检出上会自跳。观测性清单只等待两道覆盖率门禁结算,因此在覆盖率失败后仍会运行;各门禁自身的 `needs` 依赖仍要求前置门禁通过。Linux 让 4 个插桩分区进程与 2 个豁免 worker 重叠运行,在保持每个插桩进程只有 1 个 worker 的同时,恢复普通路径原有的 4 路插桩并发。 ## 失败与输出语义 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88653de55a..c593c63494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -515,8 +515,8 @@ jobs: # No build before coverage, matching the Linux lane: workspace imports # resolve to src through the tsconfig paths map, and the lib-consuming # suites (webworker-packer image-loadable, webworker-runtime - # transform-corpus, session-persistence-sqlite built-package) self-skip - # on unbuilt checkouts. + # transform-corpus, session-persistence-sqlite built-package, client + # ui-trajectory client-bundle) self-skip on unbuilt checkouts. - name: Run Windows coverage shell: pwsh run: pnpm run check:ci:coverage diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e0d66f6594..ee78959a17 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -157,10 +157,10 @@ describe('CI workflow', () => { // Windows coverage runs zero-build like the Linux lane: workspace imports // resolve to src through the tsconfig paths map, and the lib-consuming // suites (webworker-packer image-loadable, webworker-runtime - // transform-corpus, session-persistence-sqlite built-package) self-skip - // on unbuilt checkouts. The regex catches a regression spelled as - // 'corepack pnpm run build' or folded into a multi-line run block, which - // an exact string match would miss. + // transform-corpus, session-persistence-sqlite built-package, client + // ui-trajectory client-bundle) self-skip on unbuilt checkouts. The regex + // catches a regression spelled as 'corepack pnpm run build' or folded into + // a multi-line run block, which an exact string match would miss. expect(coverageCommands.every(step => !/\bpnpm\s+run\s+build(?:\s|$)/.test(step.run))).toBe(true) // windows-native-tests runs the Windows-specific specs. From 0868e5d1281ad8240c4d9b5d01f7e33eff0e6f28 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 12:12:16 +0800 Subject: [PATCH 05/43] test: make windows coverage timing assertions deterministic cache.spec.ts polled fire-and-forget fail-soft writes with a fixed 40ms settle(); contended runners drain the write after the window, so the warn/row assertions flaked. Poll the observable outcome with vi.waitFor (5s) instead, matching the file's existing cold-read write-back pattern. The sdk-client and subagent-dsh-sdk dispose-ladder tests passed tight confirmation budgets (disposeGraceMs 100-300ms) to real children; on a contended runner the SIGKILL exit edge can arrive after the budget and close() misreports a slow reap as failure. Use the product-default budgets (disposeGraceMs 3000ms) for the real-child cases; the fake-child negative cases in dispose.spec.ts keep the 10ms bound. --- ...dows-coverage-flaky-test-budgets.i18n.yaml | 6 ++ ...-31-windows-coverage-flaky-test-budgets.md | 51 +++++++++++++++ ...-windows-coverage-flaky-test-budgets.zh.md | 51 +++++++++++++++ packages/sdk/client/tests/sdk-client.spec.ts | 9 ++- .../tests/cache.spec.ts | 62 ++++++++++++------- .../tests/subagent-dsh-sdk.spec.ts | 8 ++- 6 files changed, 157 insertions(+), 30 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.md create mode 100644 .agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.zh.md diff --git a/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.i18n.yaml new file mode 100644 index 0000000000..ef4f139b15 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.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/process/2026-08-31-windows-coverage-flaky-test-budgets.md +2026-08-31-windows-coverage-flaky-test-budgets.md: 475bd79adf2721202d860d3c7bc86e1e4b68d6c2 +2026-08-31-windows-coverage-flaky-test-budgets.zh.md: 2a16ba8c6ea983d7ad457eb579c9e5e367cf4d68 diff --git a/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.md b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.md new file mode 100644 index 0000000000..475bd79adf --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.md @@ -0,0 +1,51 @@ +# Agent Note: deterministic assertions and dispose budgets for the Windows coverage lane + +Status: implemented + +English | [中文](2026-08-31-windows-coverage-flaky-test-budgets.zh.md) + +## Problem + +The `windows node 24 / coverage` lane is excluded from `all-checks-passed.needs` because it is unstable, not because its verdict is unimportant. The instability is a set of timing-sensitive tests that pass on a quiet runner and fail on a contended one. Two failure shapes recur across many PRs (3184, 3185, 3179, 3181) and are unrelated to the PR diffs that trigger them: + +1. `packages/session/session-projection-cache/tests/cache.spec.ts` — `SessionProjectionCache` writes are fail-soft and fire-and-forget (the event listener calls `void flushSoft(...)`, `coldSnapshot` calls `void this.put(...)`). Six tests asserted the durable outcome after a fixed `settle()` of 40 ms. On a contended runner the write does not drain within 40 ms, so the mock assertion fails with `AssertionError: expected "Mock" to be called with arguments: [ StringContaining{…} ]` at the `expect(warn).toHaveBeenCalledWith(...)` lines, or the stored-row assertion reads a stale cut. These are the two cache.spec cases that fail on every affected run. +2. `packages/sdk/client/tests/sdk-client.spec.ts` and `packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts` — dispose ladder tests launch a real child process and pass tight confirmation budgets (`disposeGraceMs: 100`–`300`). On a contended runner the child's exit edge after SIGKILL can arrive after the budget, so `close()` rejects with `runtime process did not exit within 100ms after SIGKILL` even though the child was reaped correctly. The product defaults are `disposeEofGraceMs: 6000` / `disposeGraceMs: 3000`; the tight values were test-only speed choices that misreport slow reaps as dispose failures. + +A separate, historical coverage gap in `packages/workflow/workflow-worker-thread` (host.ts/index.ts below the per-file 100% gate) was tracked as part of this lane's instability. Investigation in this change found that the local reproduction was a DSH-session environment artifact, not a code defect: the session exports `TSX_TSCONFIG_PATH` pointing at the DSH staging checkout's tsconfig, which redirects the tsx-in-worker resolution of workspace bare specifiers to the staging copy and drops their named exports. With `TSX_TSCONFIG_PATH` unset, `workflow-worker-thread.spec.ts` passes 54/54. The Windows-side reports of that gap predate the ReFS clone install (#3342) and have not recurred since; any recurrence needs Windows-side per-line uncovered lists before it can be attributed. + +## Decision + +Replace every fixed-wait assertion in cache.spec.ts with `vi.waitFor` polling of the observable outcome, with a 5 s timeout (the same pattern the file already used for the cold-read write-back cases since `7746ed64f0`). The two mock-assertion cases poll for the warning call itself: + +```ts ignore-check +await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) +}, { timeout: 5_000 }) +``` + +The polling assertion still fails when the condition never becomes true — the negative case (an impossible string) times out and fails the test — so the fail-soft contract stays enforced. + +For the dispose-ladder tests, pass the product-default budgets instead of the tight test-only values: + +- `sdk-client.spec.ts`: `disposeGraceMs` `100` → `3_000` (bounds-profile case), `1_000` → `3_000` (SIGTERM ladder), `300` → `3_000` (SIGKILL escalation). +- `subagent-dsh-sdk.spec.ts`: the concurrent diagnostic-isolation case uses `DEFAULT_SHUTDOWN_TIMEOUT_MS` / `DEFAULT_DISPOSE_EOF_GRACE_MS` / `DEFAULT_DISPOSE_GRACE_MS` from `run.ts` instead of `100`/`200`/`200`. + +The dispose.spec.ts negative cases (`disposeGraceMs: 10` with a fake child that never exits) still verify that a truly stuck child fails the ladder within its budget; only the real-child tests with overly tight budgets were widened. + +## Verification + +- cache.spec.ts: 17/17 pass locally; negative case (impossible warning string) fails via the `vi.waitFor` timeout. +- sdk-client.spec.ts: 42/42 pass locally; dispose.spec.ts 16/16 pass (the 10 ms refused/accepted negative cases still fail correctly). +- subagent-dsh-sdk.spec.ts: 55/55 pass locally. +- workflow-worker-thread.spec.ts: 54/54 pass locally with `TSX_TSCONFIG_PATH` unset — no code change made for the historical coverage gap. +- CI on this PR: the windows coverage lane should stop failing on these tests. + +## Alternatives considered + +**Keep the fixed settle windows and rerun flaky lanes.** Reruns eventually pass, but every affected PR pays a re-run cycle and the lane stays excluded from `all-checks-passed.needs`. The polled assertion costs nothing when the write is prompt and removes the timing dependency entirely, matching the file's existing `vi.waitFor` pattern from `7746ed64f0`. + +**Keep the tight dispose budgets and treat SIGKILL timeouts as runner faults.** A truly stuck child must still fail the ladder, which the fake-child negative cases in dispose.spec.ts already cover at 10 ms. The real-child cases were widened to the product defaults because they measure the ladder's escalation, not a performance bound, and a contended runner's exit edge is not a code defect. + +## Consequences + +The windows coverage lane keeps its per-file 100% gate while its tests no longer depend on a 40 ms wall-clock window or a 100–300 ms SIGKILL confirmation. The two cache.spec mock-assertion cases and the concurrent subagent-dsh-sdk case stop failing under runner contention, so the lane's flake rate drops without weakening any assertion: every polled condition still fails on timeout, and every dispose negative case still bounds a stuck child. diff --git a/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.zh.md b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.zh.md new file mode 100644 index 0000000000..2a16ba8c6e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-windows-coverage-flaky-test-budgets.zh.md @@ -0,0 +1,51 @@ +# Agent Note:Windows coverage lane 的确定性断言与 dispose 预算 + +Status: implemented + +[English](2026-08-31-windows-coverage-flaky-test-budgets.md) | 中文 + +## Problem + +`windows node 24 / coverage` lane 不在 `all-checks-passed.needs` 里,是因为它不稳定,而不是它的结论不重要。不稳定来自一组对时序敏感的测试:在空闲 runner 上通过,在争抢的 runner 上失败。两种失败形态在多个 PR(3184、3185、3179、3181)反复出现,与触发它们的 PR diff 无关: + +1. `packages/session/session-projection-cache/tests/cache.spec.ts` —— `SessionProjectionCache` 的写入是 fail-soft 且 fire-and-forget(事件监听器调 `void flushSoft(...)`,`coldSnapshot` 调 `void this.put(...)`)。六个测试在固定 `settle()` 40 ms 后断言持久化结果。争抢的 runner 上写入 40 ms 内没有排空,mock 断言以 `AssertionError: expected "Mock" to be called with arguments: [ StringContaining{…} ]` 失败(在 `expect(warn).toHaveBeenCalledWith(...)` 行),或 stored-row 断言读到陈旧 cut。这正是每次受影响 run 都失败的 cache.spec 两个用例。 +2. `packages/sdk/client/tests/sdk-client.spec.ts` 与 `packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts` —— dispose 梯子测试启动真实子进程并传入紧的确认预算(`disposeGraceMs: 100`–`300`)。争抢的 runner 上 SIGKILL 后子进程的退出边缘可能晚于预算到达,于是 `close()` 以 `runtime process did not exit within 100ms after SIGKILL` reject,即使子进程已被正确回收。产品默认是 `disposeEofGraceMs: 6000` / `disposeGraceMs: 3000`;紧值是测试只为提速的选择,却把慢回收误报成 dispose 失败。 + +另有一个历史性的覆盖率缺口在 `packages/workflow/workflow-worker-thread`(host.ts/index.ts 低于 per-file 100% 门禁),曾被当作本 lane 不稳定的一部分跟踪。本次调查发现本机复现是 DSH 会话的环境假象而非代码缺陷:会话导出了指向 DSH staging checkout tsconfig 的 `TSX_TSCONFIG_PATH`,把 tsx-in-worker 对 workspace bare specifier 的解析重定向到 staging 副本并丢掉了 named exports。unset `TSX_TSCONFIG_PATH` 后 `workflow-worker-thread.spec.ts` 54/54 通过。Windows 侧对该缺口的报告早于 ReFS clone 安装(#3342),此后未再出现;任何复发都需要 Windows 侧逐行未覆盖清单才能归因。 + +## Decision + +把 cache.spec.ts 里每个固定等待断言改成用 `vi.waitFor` 轮询可观察结果,超时 5 s(与该文件自 `7746ed64f0` 起在 cold-read write-back 用例中使用的模式一致)。两个 mock 断言用例轮询警告调用本身: + +```ts ignore-check +await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) +}, { timeout: 5_000 }) +``` + +轮询断言在条件永远不成立时仍会失败——负例(不可能的字符串)超时并使测试失败——所以 fail-soft 契约仍被强制。 + +对 dispose 梯子测试,改用产品默认预算,去掉只属于测试的紧值: + +- `sdk-client.spec.ts`:`disposeGraceMs` `100` → `3_000`(bounds-profile 用例)、`1_000` → `3_000`(SIGTERM 梯子)、`300` → `3_000`(SIGKILL 升级)。 +- `subagent-dsh-sdk.spec.ts`:并发诊断隔离用例改用 `run.ts` 的 `DEFAULT_SHUTDOWN_TIMEOUT_MS` / `DEFAULT_DISPOSE_EOF_GRACE_MS` / `DEFAULT_DISPOSE_GRACE_MS`,而不是 `100`/`200`/`200`。 + +dispose.spec.ts 的负例(`disposeGraceMs: 10`,fake child 永不退出)仍验证真正卡住的子进程会在预算内使梯子失败;只有真实子进程用例的过紧预算被放宽。 + +## Verification + +- cache.spec.ts:本地 17/17 通过;负例(不可能的警告字符串)经 `vi.waitFor` 超时失败。 +- sdk-client.spec.ts:本地 42/42 通过;dispose.spec.ts 16/16 通过(10 ms refused/accepted 负例仍正确失败)。 +- subagent-dsh-sdk.spec.ts:本地 55/55 通过。 +- workflow-worker-thread.spec.ts:unset `TSX_TSCONFIG_PATH` 后本地 54/54 通过——历史覆盖率缺口未改代码。 +- CI on this PR:windows coverage lane 应不再因这些测试失败。 + +## Alternatives considered + +**保留固定 settle 窗口并重跑 flaky lane。** 重跑最终会通过,但每个受影响的 PR 都要付出一次重跑周期,lane 仍被排除在 `all-checks-passed.needs` 之外。轮询断言在写入及时时零成本,并完全消除时序依赖,与该文件自 `7746ed64f0` 起已有的 `vi.waitFor` 模式一致。 + +**保留紧 dispose 预算并把 SIGKILL 超时当作 runner 故障。** 真正卡住的子进程仍必须让梯子失败——dispose.spec.ts 的 fake-child 负例已用 10 ms 覆盖。真实子进程用例放宽到产品默认,因为它们测的是梯子的升级路径而不是性能上限,争抢 runner 上的退出边缘不是代码缺陷。 + +## Consequences + +windows coverage lane 保留 per-file 100% 门禁,而其测试不再依赖 40 ms 墙钟窗口或 100–300 ms 的 SIGKILL 确认。cache.spec 两个 mock 断言用例与 subagent-dsh-sdk 并发用例在 runner 争抢下不再失败,lane 的 flake 率下降而不削弱任何断言:每个轮询条件超时仍失败,每个 dispose 负例仍约束卡住的子进程。 diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 48d5fb5c14..164f0083bb 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -308,7 +308,10 @@ describe('HarnessClient', () => { description: 'dsh profile "profile-without-sdk-server"', initializeTimeoutMs: 50, disposeEofGraceMs: 100, - disposeGraceMs: 100, + // Wide SIGKILL confirmation: the hang-init child may still be + // starting up on a contended runner when close() escalates, so a + // tight window misreports a slow reap as a dispose failure. + disposeGraceMs: 3_000, }, )) cleanups.push(() => client.close()) @@ -416,7 +419,7 @@ describe('HarnessClient', () => { const sigtermFile = join(dir, 'sigterm.txt') const client = processClient(fakeLaunch( { FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile }, - { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 }, + { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 3_000 }, )) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) await client.close() @@ -430,7 +433,7 @@ describe('HarnessClient', () => { it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => { const client = processClient(fakeLaunch( { FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' }, - { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 }, + { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 3_000 }, )) await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }) // Resolves (does not hang or reject): the SIGKILL rung reaped the child. diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 8a851ce2d4..6fe271871e 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -141,9 +141,6 @@ async function seedRecord( await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version, record: { identity, rows } })) } -/** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */ -const settle = () => new Promise(resolve => setTimeout(resolve, 40)) - afterEach(async () => { vi.useRealTimers() await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) @@ -157,12 +154,14 @@ describe('SessionProjectionCache write policy', () => { mark(session, ['a']) // Creation already wrote the init cut; the mark is throttled, so the // stored row is still the creation-time cut (no marks folded). - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) + }, { timeout: 5_000 }) const end = endTurn(session) - await settle() - const rows = await storedRows(root, session.id) - expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } }) + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']) + .toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } }) + }, { timeout: 5_000 }) }) it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => { @@ -173,9 +172,10 @@ describe('SessionProjectionCache write policy', () => { const session = ctx.sessions.create(SessionId('seeded'), { seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[], }) - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.val) - .toEqual({ marks: ['seed'] }) + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']?.val) + .toEqual({ marks: ['seed'] }) + }, { timeout: 5_000 }) }) it('writes at session disposal (detach, the live-to-cold moment)', async () => { @@ -188,8 +188,10 @@ describe('SessionProjectionCache write policy', () => { if (session === undefined) throw new Error('session was not created') mark(session, ['live']) await owner.dispose() - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] }) + const detached = session + await vi.waitFor(async () => { + expect((await storedRows(root, detached.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] }) + }, { timeout: 5_000 }) }) it('flushes when the in-turn event count reaches the configured threshold', async () => { @@ -197,11 +199,13 @@ describe('SessionProjectionCache write policy', () => { const session = ctx.sessions.create(SessionId('count')) mark(session, ['1']) mark(session, ['2']) - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut + }, { timeout: 5_000 }) mark(session, ['3']) - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] }) + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] }) + }, { timeout: 5_000 }) }) it('flushes on the configured interval when the count threshold is not reached', async () => { @@ -270,15 +274,22 @@ describe('SessionProjectionCache write policy', () => { const session = ctx.sessions.create(SessionId('fail-soft')) mark(session, ['x']) endTurn(session) - await settle() - expect(await storedRows(root, session.id)).toBeUndefined() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) + // The failed creation/turn-end writes are fire-and-forget: wait for the + // warn (the write actually failed), then assert no row landed — the + // property under test is that a failed write leaves no partial row. + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) + }, { timeout: 5_000 }) + await vi.waitFor(async () => { + expect(await storedRows(root, session.id)).toBeUndefined() + }, { timeout: 5_000 }) // Self-heal: once the blocker clears, the next mandatory point writes. await rm(recordPath(root, session.id), { recursive: true }) mark(session, ['y']) endTurn(session) - await settle() - expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] }) + await vi.waitFor(async () => { + expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] }) + }, { timeout: 5_000 }) }) }) @@ -473,7 +484,10 @@ describe('SessionProjectionCache cold-read seeding', () => { const meta = headerOf(SessionId('cold-fail')) await mkdir(recordPath(root, meta.id), { recursive: true }) expect(ctx.sessionProjectionCache.coldSnapshot(meta, [])).toBeDefined() - await settle() - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed')) + // The failed write-back is fire-and-forget: poll for the warn instead of + // assuming a fixed settle window (slow runners exceed it). + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed')) + }, { timeout: 5_000 }) }) }) diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 1170c59f0b..826060b38d 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -621,9 +621,11 @@ describe('dsh-subagent-dsh-sdk provider', () => { provider: 'p', model: 'm', env: { FAKE_REASON_KIND: reason }, - shutdownTimeoutMs: 100, - disposeEofGraceMs: 200, - disposeGraceMs: 200, + // Product-default dispose budgets: two real children are reaped under + // runner contention, where tight windows misreport slow SIGKILL reaps. + shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, }) const [errored, unknown] = await Promise.all([start('error'), start('unknown-reason')]) const [errorResult, unknownResult] = await Promise.all([errored.result, unknown.result]) From aa54467476266ced809933fc6db6d9ca40cbf977 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 14:24:51 +0800 Subject: [PATCH 06/43] Merge origin/master and repair the dead note link from PR 2573 Bring in master through cf6750b10 (including the gate-runner fail-fast change) and drop the stale fixture reference in the case-insensitive-path-round-trips note: PR 2573 linked a fixture that PR 3128 had already removed, so every pull request's markdown-links gate failed on master's own note. The claim stands without naming the deleted file; verify-md-links now resolves all 2199 files locally. --- .../2026-08-14-case-insensitive-path-round-trips.i18n.yaml | 4 ++-- .../testing/2026-08-14-case-insensitive-path-round-trips.md | 2 +- .../2026-08-14-case-insensitive-path-round-trips.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml index 35c93a6998..567b342398 100644 --- a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.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/testing/2026-08-14-case-insensitive-path-round-trips.md -2026-08-14-case-insensitive-path-round-trips.md: 5ff03a4fb55fc3dc136b22850f938685ed15a44f -2026-08-14-case-insensitive-path-round-trips.zh.md: 6ad6121653badd3f636dda822a95fa7672b22cc3 +2026-08-14-case-insensitive-path-round-trips.md: 43cfa06f430418923ebc6d8c20bd23656cab5f9b +2026-08-14-case-insensitive-path-round-trips.zh.md: 98bac2b95b081b3e7bc87c3556b9d66ac7242b23 diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md index 5ff03a4fb5..43cfa06f43 100644 --- a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.md @@ -16,7 +16,7 @@ A host reaches that state when `tmpdir()` and `process.cwd()` share a path prefi The expectation resolves the same relative root the plugin receives. Both sides pass through one `resolve(cwd, relative)` call, so the case-insensitive round-trip cannot place two spellings on the two sides of the comparison. -This is a test-only change. The platform treats both spellings as the same file, so storage behaviour does not depend on which spelling `resolve()` produces. The string itself stays observable: hook payloads carry it as `transcript_path` and the shell contributor exports it as `DSH_SESSION_JSONL`, so a consumer that compares those strings can still see the difference. Composition fixtures such as [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) set a relative session root — but the plugin resolves whatever it receives before use, so a relative root reaches disk as one spelling rather than two. +This is a test-only change. The platform treats both spellings as the same file, so storage behaviour does not depend on which spelling `resolve()` produces. The string itself stays observable: hook payloads carry it as `transcript_path` and the shell contributor exports it as `DSH_SESSION_JSONL`, so a consumer that compares those strings can still see the difference. Composition fixtures set a relative session root — but the plugin resolves whatever it receives before use, so a relative root reaches disk as one spelling rather than two. The case still asserts what it names: with the plugin's `resolve(config.root)` reduced to `config.root`, so a relative root is no longer resolved, the case fails. diff --git a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md index 6ad6121653..98bac2b95b 100644 --- a/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md +++ b/.agents/notes/implemented/testing/2026-08-14-case-insensitive-path-round-trips.zh.md @@ -16,7 +16,7 @@ Status: implemented 期望值改为解析「插件实际收到的那个相对 root」。两侧都经过同一次 `resolve(cwd, relative)`,大小写不敏感的往返就不可能把两种拼写分别放到比较的两边。 -这是只改测试的变更。平台把两种拼写视为同一个文件,所以存储行为不依赖 `resolve()` 产出哪种拼写。字符串本身仍可被观察到:hook 载荷以 `transcript_path` 携带它,shell 贡献者以 `DSH_SESSION_JSONL` 导出它,因此比较这些字符串的消费方仍能看出差异。组合 fixture(测试前置数据)如 [`apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml`](../../../../apps/cli/tests/profiles/headless/tests/fixtures/cli.cordis.yml) 就设置了相对的会话 root——但插件会先解析收到的值再使用,因此相对 root 落盘时只有一种拼写而非两种。 +这是只改测试的变更。平台把两种拼写视为同一个文件,所以存储行为不依赖 `resolve()` 产出哪种拼写。字符串本身仍可被观察到:hook 载荷以 `transcript_path` 携带它,shell 贡献者以 `DSH_SESSION_JSONL` 导出它,因此比较这些字符串的消费方仍能看出差异。组合 fixture(测试前置数据)设置相对的会话 root——但插件会先解析收到的值再使用,因此相对 root 落盘时只有一种拼写而非两种。 该用例仍然在验证它声称的东西:把插件的 `resolve(config.root)` 降级成 `config.root`(即不再解析相对 root)后,用例转红。 From 4df85c85fff30f7d085ce42832eff3023179610a Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 31 Aug 2026 14:20:02 +0800 Subject: [PATCH 07/43] feat(issue-management): initialize Issue start dates on PR open --- ...-event-directed-pr-review-status.i18n.yaml | 4 +- ...6-08-10-event-directed-pr-review-status.md | 2 +- ...8-10-event-directed-pr-review-status.zh.md | 2 +- ...8-31-pr-opened-issue-start-dates.i18n.yaml | 6 + .../2026-08-31-pr-opened-issue-start-dates.md | 39 +++++ ...26-08-31-pr-opened-issue-start-dates.zh.md | 39 +++++ .github/issue-management/config.json | 2 + .github/issue-management/policy.mjs | 118 ++++++++++++++- .github/issue-management/policy.test.mjs | 139 ++++++++++++++++++ scripts/ci-workflow.spec.ts | 1 + 10 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md create mode 100644 .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml index d8c333dd88..f4cca1265e 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md -2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab -2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a +2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd +2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md index 3ed6038929..47f6f1731b 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. -The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. +The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference. [Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md index 1fa8650057..8062ab5b1f 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 -处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。 +状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。 [Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml new file mode 100644 index 0000000000..a47ef83d8d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.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/process/2026-08-31-pr-opened-issue-start-dates.md +2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48 +2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79 diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md new file mode 100644 index 0000000000..f8976b8b04 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md @@ -0,0 +1,39 @@ +# Agent Note: PR-opened Issue start dates + +Status: implemented + +English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md) + +## Problem + +The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work. + +Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request. + +## Decision + +The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty. + +The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request. + +[Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement. + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription. + +## Alternatives considered + +**Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date. + +**Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads. + +**Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics. + +**Overwrite an existing date.** A later pull request must not replace a manual plan or the date written for earlier work, so the mutation follows an empty-value read. + +## Consequences + +Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests. + +The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win. diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md new file mode 100644 index 0000000000..3142bad500 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 在 PR 创建时设置 Issue 开始日期 + +Status: implemented + +[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文 + +## 问题 + +Issue Project 使用 `Start date` 字段记录已规划工作的开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。 + +如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。 + +## 决策 + +Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。 + +配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。 + +[由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。 + +## 考虑过的替代方案 + +**使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。 + +**处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。 + +**仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。 + +**覆盖已有日期。** 后续 PR 不得替换人工计划或为较早工作写入的日期,因此 mutation 先读取并只处理空值。 + +## 后果 + +只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。 + +空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。 diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json index 5dc925f245..85fd4086bd 100644 --- a/.github/issue-management/config.json +++ b/.github/issue-management/config.json @@ -5,6 +5,8 @@ "projectTitle": "DSH Issue Management", "lifecycleActor": "dsh-issue-management", "priorityField": "Priority", + "startDateField": "Start date", + "projectTimeZone": "Asia/Shanghai", "allowUnassignedOwner": true, "statuses": [ "Inbox", diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 24a82cf15f..016dfed2f7 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -52,6 +52,13 @@ for (const status of ['In progress', 'In review']) { if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { throw new Error('config.lifecycleActor 未设置') } +if (typeof config.startDateField !== 'string' || !config.startDateField) { + throw new Error('config.startDateField 未设置') +} +if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) { + throw new Error('config.projectTimeZone 未设置') +} +Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone }) /** * Return Markdown outside balanced details elements. @@ -215,6 +222,29 @@ export function nextResolvingIssueStatus(currentStatus, command, currentStatusAc return currentIndex >= 0 && currentIndex < targetIndex ? target : null } +/** + * Convert a GitHub timestamp to a Project date in one configured time zone. + * @param {string} timestamp ISO timestamp. + * @param {string} timeZone IANA time-zone name. + * @returns {string} Calendar date in YYYY-MM-DD form. + */ +export function projectDate(timestamp, timeZone = config.projectTimeZone) { + const instant = new Date(timestamp) + if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`) + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .formatToParts(instant) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]), + ) + return `${parts.year}-${parts.month}-${parts.day}` +} + function stripIgnoredMarkdown(body) { const lines = body.replace(//g, '').split(/\r?\n/) const kept = [] @@ -437,7 +467,7 @@ async function issueSnapshot(number, status = undefined) { } } -async function projectContext(number, includeStatusActor = false) { +async function projectContext(number, includeStatusActor = false, includeStartDate = false) { const data = await graphql( `query( $organization: String! @@ -445,6 +475,8 @@ async function projectContext(number, includeStatusActor = false) { $number: Int! $project: Int! $includeStatusActor: Boolean! + $includeStartDate: Boolean! + $startDateField: String! ) { organization(login: $organization) { projectV2(number: $project) { @@ -452,7 +484,8 @@ async function projectContext(number, includeStatusActor = false) { title fields(first: 50) { nodes { - ... on ProjectV2SingleSelectField { id name options { id name } } + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { id name dataType options { id name } } } } } @@ -477,6 +510,10 @@ async function projectContext(number, includeStatusActor = false) { fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name optionId } } + startDateValue: fieldValueByName(name: $startDateField) + @include(if: $includeStartDate) { + ... on ProjectV2ItemFieldDateValue { date } + } } } } @@ -488,6 +525,8 @@ async function projectContext(number, includeStatusActor = false) { number, project: config.projectNumber, includeStatusActor, + includeStartDate, + startDateField: config.startDateField, }, ) const project = data.organization?.projectV2 @@ -496,15 +535,24 @@ async function projectContext(number, includeStatusActor = false) { if (!issue) throw new Error(`#${number} 不存在`) const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') + const startDateField = includeStartDate + ? project.fields.nodes.find((field) => field?.name === config.startDateField) + : null + if (includeStartDate && !startDateField) { + throw new Error(`Project 缺少 ${config.startDateField} 字段`) + } + if (startDateField && startDateField.dataType !== 'DATE') { + throw new Error(`Project ${config.startDateField} 字段必须为 Date`) + } const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) const latestStatusEvent = issue.timelineItems?.nodes ?.filter((event) => event?.project?.id === project.id) .at(-1) const statusActor = - latestStatusEvent?.status === item?.fieldValueByName?.name + latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name ? (latestStatusEvent.actor?.login ?? null) : null - return { project, issue, statusField, item, statusActor } + return { project, issue, statusField, startDateField, item, statusActor } } async function projectStatus(number) { @@ -512,8 +560,8 @@ async function projectStatus(number) { return context.item?.fieldValueByName?.name ?? null } -async function ensureProjectItem(number) { - const context = await projectContext(number) +async function ensureProjectItem(number, includeStartDate = false) { + const context = await projectContext(number, false, includeStartDate) if (context.item) return context const data = await graphql( `mutation($projectId: ID!, $contentId: ID!) { @@ -525,10 +573,58 @@ async function ensureProjectItem(number) { ) return { ...context, - item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null }, + item: { + id: data.addProjectV2ItemById.item.id, + fieldValueByName: null, + startDateValue: null, + }, } } +/** + * Initialize one Issue's Project Start date when it is empty. + * @param {number} number Same-repository Issue number. + * @param {string} date Date in YYYY-MM-DD form. + * @returns {Promise} Resolves after the conditional Project update. + */ +export async function initializeIssueStartDate(number, date) { + const context = await ensureProjectItem(number, true) + if (context.item.startDateValue?.date) return + await graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {date: $date} + }) { projectV2Item { id } } + }`, + { + projectId: context.project.id, + itemId: context.item.id, + fieldId: context.startDateField.id, + date, + }, + ) +} + +/** + * Initialize every referenced Issue from a newly opened PR. + * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot. + * @param {string} action Pull-request event action. + * @param {(number: number, date: string) => Promise} initialize Date writer. + * @returns {Promise} Resolves after all eligible Issues are processed. + */ +export async function initializePullRequestStartDates( + pull, + action, + initialize = initializeIssueStartDate, +) { + if (action !== 'opened') return + const date = projectDate(pull.createdAt) + for (const number of pull.references.all) await initialize(number, date) +} + async function updateStatus(context, status) { const option = context.statusField.options.find((candidate) => candidate.name === status) if (!option) throw new Error(`Status 不存在:${status}`) @@ -631,7 +727,10 @@ async function pullRequestSnapshot(number) { async function lifecyclePullRequestSnapshot(number) { const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) - return resolvingReferencesSnapshot(number, pull) + return { + ...(await resolvingReferencesSnapshot(number, pull)), + createdAt: pull.created_at, + } } async function transitionResolvingIssues(pull, command) { @@ -683,6 +782,9 @@ async function runLifecycle(eventName, event) { if (!command) return const pull = await lifecyclePullRequestSnapshot(event.pull_request.number) await transitionResolvingIssues(pull, command) + if (eventName === 'pull_request') { + await initializePullRequestStartDates(pull, event.action) + } } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index c03a7c3513..d853ff3ba0 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,8 +3,11 @@ import test from 'node:test' import { countVisibleUnits, + initializeIssueStartDate, + initializePullRequestStartDates, nextResolvingIssueStatus, parseReferences, + projectDate, retainIssueReferences, resolvingIssueStatusCommand, requiresPullRequestPolicy, @@ -13,6 +16,63 @@ import { validatePullRequest, } from './policy.mjs' +const projectGraphqlData = ({ + projectItem = true, + startDate = null, + startDateField = true, + startDateType = 'DATE', +} = {}) => ({ + organization: { + projectV2: { + id: 'project-id', + title: 'DSH Issue Management', + fields: { + nodes: [ + { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] }, + ...(startDateField + ? [{ id: 'start-date-field-id', name: 'Start date', dataType: startDateType }] + : []), + ], + }, + }, + }, + repository: { + issue: { + id: 'issue-id', + projectItems: { + nodes: projectItem + ? [ + { + id: 'item-id', + project: { id: 'project-id' }, + fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' }, + startDateValue: startDate === null ? null : { date: startDate }, + }, + ] + : [], + }, + }, + }, +}) + +const mockGraphql = (t, resolve) => { + const requests = [] + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + t.mock.method(globalThis, 'fetch', async (url, options) => { + assert.equal(url, 'https://api.github.com/graphql') + assert.equal(options.headers.Authorization, 'Bearer test-token') + const request = JSON.parse(options.body) + requests.push(request) + return Response.json({ data: resolve(request, requests.length - 1) }) + }) + return requests +} + const withDetails = (summary) => `${summary}\n\n
验收与细节待补充。
` @@ -171,6 +231,85 @@ test('separates resolving and informational references', () => { ) }) +test('converts PR creation timestamps to Shanghai Project dates', () => { + assert.equal(projectDate('2026-08-27T15:59:59Z', 'Asia/Shanghai'), '2026-08-27') + assert.equal(projectDate('2026-08-27T16:00:00Z', 'Asia/Shanghai'), '2026-08-28') + assert.throws(() => projectDate('invalid', 'Asia/Shanghai'), /无效的 PR 创建时间/) +}) + +test('initializes every referenced Issue only for a PR opened event', async () => { + const writes = [] + const pull = { + createdAt: '2026-08-27T16:00:00Z', + references: { all: [4, 7, 12] }, + } + const initialize = async (number, date) => writes.push({ number, date }) + + await initializePullRequestStartDates(pull, 'opened', initialize) + assert.deepEqual(writes, [ + { number: 4, date: '2026-08-28' }, + { number: 7, date: '2026-08-28' }, + { number: 12, date: '2026-08-28' }, + ]) + + for (const action of ['edited', 'synchronize', 'reopened']) { + await initializePullRequestStartDates(pull, action, initialize) + } + assert.equal(writes.length, 3) +}) + +test('writes an empty Project Start date with the configured field', async (t) => { + const requests = mockGraphql(t, (request) => { + if (request.query.includes('query(')) return projectGraphqlData() + return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } } + }) + + await initializeIssueStartDate(42, '2026-08-28') + + assert.equal(requests.length, 2) + assert.match(requests[1].query, /value: \{date: \$date\}/) + assert.deepEqual(requests[1].variables, { + projectId: 'project-id', + itemId: 'item-id', + fieldId: 'start-date-field-id', + date: '2026-08-28', + }) +}) + +test('preserves an existing Project Start date', async (t) => { + const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' })) + + await initializeIssueStartDate(42, '2026-08-28') + + assert.equal(requests.length, 1) +}) + +test('adds a referenced Issue to the Project before setting Start date', async (t) => { + const requests = mockGraphql(t, (request) => { + if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false }) + if (request.query.includes('addProjectV2ItemById')) { + return { addProjectV2ItemById: { item: { id: 'new-item-id' } } } + } + return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } } + }) + + await initializeIssueStartDate(42, '2026-08-28') + + assert.equal(requests.length, 3) + assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' }) + assert.equal(requests[2].variables.itemId, 'new-item-id') +}) + +test('rejects a missing or non-Date Start date field', async (t) => { + let response = projectGraphqlData({ startDateField: false }) + const requests = mockGraphql(t, () => response) + + await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start date 字段/) + response = projectGraphqlData({ startDateType: 'TEXT' }) + await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start date 字段必须为 Date/) + assert.equal(requests.length, 2) +}) + test('does not treat pull request references as Issue associations', () => { const references = { all: [123, 1180, 1181], diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e33df9aeb3..7d8aca63e7 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -652,6 +652,7 @@ describe('Issue lifecycle workflow', () => { // review events. const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + expect(lifecyclePullRequest.types).toContain('opened') expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) From ff21366916bbca2fbd037190c2b062add495d7bf Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 28 Aug 2026 16:35:19 +0800 Subject: [PATCH 08/43] fix(client): complete highlighted commands with Tab --- ...input-machine-and-slash-pipeline.i18n.yaml | 4 +- ...25-web-input-machine-and-slash-pipeline.md | 2 +- ...web-input-machine-and-slash-pipeline.zh.md | 2 +- .../tests/goal-command-presentation.e2e.ts | 10 +++-- .../src/client/input/editor/keymap.ts | 6 +-- .../tests/keymap-routing.client.spec.tsx | 4 +- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 2 +- packages/client/ui-input-trigger/README.zh.md | 2 +- .../ui-input-trigger/src/client/controller.ts | 25 +++++++----- .../tests/service.client.spec.ts | 39 ++++++++++++++++--- 11 files changed, 70 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 6e66ed20f5..3e59e7e702 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 3508de5e8a3980a87c344c5b76c060f6119ee686 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: eebfdae780157dfd0dace1386169c5fee8c1d564 +2026-07-25-web-input-machine-and-slash-pipeline.md: 69899efcda42eb1087aaa68d1eba8c08dd14f361 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7e37dd67a2d5a7943a8c601a890d2de7489b227d diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 3508de5e8a..69899efcda 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -48,7 +48,7 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the composer surface; ↑↓/Enter/Escape are intercepted; Tab settles a highlighted completion, using the candidate's drill action when available and its ordinary pick otherwise, while no highlight preserves native focus traversal; all arbitration passes the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's composer selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index eebfdae780..7e37dd67a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -48,7 +48,7 @@ Status: implemented 对「命令」零知识的触发/菜单/pick 流水线: - 服务只有 source 注册表(`InputTriggerSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;流水线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、每会话 menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在编辑器表面;↑↓/Enter/Escape 会被拦截;Tab 会选定高亮补全项,候选项可下钻时走 drill 动作,否则走普通 pick,无高亮时保留原生焦点遍历;所有仲裁都经过 IME composition 守卫,唯一例外是 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的编辑器 selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个会话作用域出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格会话输入体 diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 9f4ad2fc53..7f0bd5c37f 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -43,15 +43,19 @@ describe('web e2e: /goal human transcript presentation', () => { await scaffold?.close() }) - it('shows the bare input and result from a fresh session without a model turn', async () => { + it('completes with Tab and shows the bare input and result without a model turn', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation')) await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000, }).toBe(1) const input = page.locator('[data-composer-input]').first() - await input.fill('/goal') - await input.press('Enter') + await input.fill('/go') + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' }) + .waitFor({ timeout: 10_000 }) + await input.press('Tab') await expect.poll(() => input.textContent()).toBe('/goal ') + await expect.poll(() => menu.count()).toBe(0) await input.press('Enter') const commandInput = page.locator('[data-command-input]') diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 3c3e46027b..492aba62df 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -68,7 +68,7 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer const arrow = (key: ArbitrateKey) => (event: KeyboardEvent | null): boolean => { const inComposition = event !== null && isComposingEvent(event, recentlyComposing) - if (handlers.arbitrate(key, inComposition) === 'consumed') { + if (handlers.arbitrate(key, inComposition) !== 'pass') { event?.preventDefault() return true } @@ -84,8 +84,8 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer }), editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), - // Tab drills into a drillable highlighted row; otherwise it passes so the - // browser keeps its native focus traversal. + // Tab acts only when the trigger menu has a highlighted completion; + // otherwise it passes so the browser keeps its native focus traversal. editor.registerCommand(KEY_TAB_COMMAND, arrow('tab'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ESCAPE_COMMAND, (event) => { // Escape layering: an open overlay closes; claimed without an overlay diff --git a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx index 3cefe6741f..ea577859ef 100644 --- a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx +++ b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx @@ -43,6 +43,7 @@ describe('keymap keydown routing', () => { registerPlainText(editor) const arbitrate = vi.fn<(key: string, composing: boolean) => 'consumed' | 'pick-highlighted' | 'pass'>() .mockReturnValueOnce('consumed') + .mockReturnValueOnce('pick-highlighted') .mockReturnValue('pass') registerComposerKeymap(editor, { arbitrate, @@ -56,8 +57,9 @@ describe('keymap keydown routing', () => { const consumed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 }) expect(arbitrate).toHaveBeenCalledWith('tab', false) expect(consumed).toBe(false) // consumed: preventDefault fired + const picked = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 }) + expect(picked).toBe(false) // picked: the completion replaces native traversal const passed = fireEvent.keyDown(root, { key: 'Tab', keyCode: 9 }) expect(passed).toBe(true) // pass: the browser keeps native focus traversal }) - }) diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 23b3ddafb1..b494cc71e5 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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-input-trigger/README.md -README.md: 1f316229327d33f48e0950a2c27e143c6c991f99 -README.zh.md: 324541120406eac8677afcfda0967540dd74848e +README.md: 83fcffbd1dbf20ad070ef9edccb74f2e5beed9e6 +README.zh.md: a7c2bbad1a1a982a99327d21d7afd6466cbd201e diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 1f31622932..83fcffbd1d 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation`; the menu then appears in the inpu ### Keyboard and mouse -The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. A candidate declaring `drill: true` carries a second verb beside the settling pick: its trailing chevron and the Tab key route the same row through `onPick` with `action: 'drill'` (every other path reports `'pick'`), and Tab passes untouched on rows without the flag so native focus traversal survives. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`. +The composer surface keeps focus while the menu is open: rows pick on mousedown, the highlight rides `aria-activedescendant`, and a pointer press outside both the menu and the composer card dismisses it. Space and Enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order; the first non-undefined answer wins, and a source can refuse a submission it cannot consume whole. Tab acts on the highlighted completion: a candidate declaring `drill: true` routes through `onPick` with `action: 'drill'`, while an ordinary candidate settles through `action: 'pick'`; without a highlight, Tab passes untouched so native focus traversal survives. A drillable row's trailing chevron exposes the same second verb to pointer users. A source implementing the optional `header` hook additionally publishes crumbs above its group: the pipeline re-polls it on every hit with the live query and whether a drill, rather than typing, produced it, and a crumb pick routes back through `onPick` with `action: 'drill'`. ----- diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 3245411204..a7c2bbad1a 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 键盘与鼠标 -菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。声明 `drill: true` 的候选行在选定 pick 之外携带第二个动词:行尾的 chevron 与 Tab 键把同一行以 `action: 'drill'` 送入 `onPick`(其余路径一律报告 `'pick'`);未声明该标记的行上 Tab 原样放行,原生焦点遍历不受影响。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。 +菜单打开期间 composer 表面保持焦点:行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载,指针落在菜单与所在 composer 卡片之外即关闭菜单。空格与回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子;第一个非 undefined 的应答胜出,source 也可以拒绝它无法整体消费的提交。Tab 会作用于高亮补全项:声明 `drill: true` 的候选项以 `action: 'drill'` 进入 `onPick`,普通候选项则以 `action: 'pick'` 完成选定;没有高亮项时 Tab 原样放行,原生焦点遍历不受影响。可下钻行尾的 chevron 向指针用户提供同一个动词。实现可选 `header` 钩子的 source 还会在其分组上方发布面包屑:管线在每次命中时用实时查询、以及该查询由下钻还是由键入产生这一事实重新询问它,点击面包屑经 `onPick` 以 `action: 'drill'` 回到该 source。 ----- diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 4afb3b5d04..7821885d85 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -214,7 +214,11 @@ export class InputTriggerController { * Keyboard arbitration while the menu is open. * @param key - intercepted key. * @param composing - inside IME composition: everything passes. - * @returns consumed / pick-highlighted / pass. + * @returns `pass` when the browser keeps the key (closed menu, no + * highlight, or a vanished candidate), `consumed` when the menu handled + * the key without a settling pick (move, close, drill descent, or a + * pending-refinement no-op), or `pick-highlighted` when the highlighted + * candidate settled and the menu closed. */ arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome { if (composing || this.disposed) return 'pass' @@ -245,16 +249,19 @@ export class InputTriggerController { return 'pick-highlighted' } case 'tab': { - // Tab drills into the highlighted candidate when it offers descent; - // otherwise the key passes so native focus behavior is untouched. if (state.highlight === null) return 'pass' const group = state.groups.find(g => g.source === state.highlight?.source) - const item = group !== undefined && group.status === 'ready' - ? group.items[state.highlight.index] - : undefined - if (item?.drill !== true) return 'pass' - this.pick(state.highlight.source, state.highlight.index, 'drill') - return 'consumed' + // Pending refinement keeps the stale highlight visible: consume the + // gesture rather than pick a stale row or let Tab move focus away. + if (group === undefined || group.status !== 'ready') return 'consumed' + const item = group.items[state.highlight.index] + if (item === undefined) return 'pass' + if (item.drill === true) { + this.pick(state.highlight.source, state.highlight.index, 'drill') + return 'consumed' + } + this.pick(state.highlight.source, state.highlight.index) + return 'pick-highlighted' } } } diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 6872645dcf..0d081edffc 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -889,7 +889,7 @@ describe('arbitrate', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) - it('tab drills into a drillable highlight and passes on plain rows', async () => { + it('tab drills into a drillable highlight and picks a plain completion', async () => { const drillable = readySource('/', 'command', [{ name: 'src', drill: true }, { name: 'plan' }], () => undefined) const { controller } = controllerBench([drillable.source]) controller.track('/s', 2, { tier: 'plain' }, 1) @@ -897,12 +897,37 @@ describe('arbitrate', () => { expect(controller.arbitrate('tab', false)).toBe('consumed') expect(drillable.picks[0]!.action).toBe('drill') expect(drillable.picks[0]!.candidate.name).toBe('src') - // Plain row (no drill flag): the key passes so native focus stays intact. + // Plain row (no drill flag): Tab settles the highlighted completion. controller.track('/s', 2, { tier: 'plain' }, 2) await tick() controller.arbitrate('down', false) - expect(controller.arbitrate('tab', false)).toBe('pass') - expect(drillable.picks).toHaveLength(1) + expect(controller.arbitrate('tab', false)).toBe('pick-highlighted') + expect(drillable.picks[1]!.action).toBe('pick') + expect(drillable.picks[1]!.candidate.name).toBe('plan') + expect(controller.menu.getSnapshot().open).toBe(false) + }) + + it('tab during a pending refinement is consumed: no pick, no focus traversal', async () => { + const picks: string[] = [] + const cmd = deferredSource('/', 'command', { + onPick: (pick) => { picks.push(pick.candidate.name); return undefined }, + }) + const { controller } = controllerBench([cmd.source]) + controller.track('/g', 2, { tier: 'plain' }, 1) + cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }]) + await tick() + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + // Refinement: previous rows and highlight stay visible while the fetch pends. + controller.track('/go', 3, { tier: 'plain' }, 2) + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + expect(controller.arbitrate('tab', false)).toBe('consumed') + expect(picks).toHaveLength(0) + expect(controller.menu.getSnapshot().open).toBe(true) + // Settled: the same gesture settles the highlighted completion. + cmd.pending[1]!.resolve([{ name: 'goal' }]) + await tick() + expect(controller.arbitrate('tab', false)).toBe('pick-highlighted') + expect(picks).toEqual(['goal']) }) it('a settling pick reports the pick action', async () => { @@ -913,19 +938,21 @@ describe('arbitrate', () => { it('IME composition passes every key untouched', async () => { const { controller } = await menuBench() - for (const key of ['up', 'down', 'enter', 'escape'] as const) { + for (const key of ['up', 'down', 'enter', 'escape', 'tab'] as const) { expect(controller.arbitrate(key, true)).toBe('pass') } expect(controller.menu.getSnapshot().open).toBe(true) }) - it('closed menu passes; an open menu without a highlight passes enter', () => { + it('closed menu passes; an open menu without a highlight passes picking keys', () => { const cmd = deferredSource('/', 'command') const { controller } = controllerBench([cmd.source]) expect(controller.arbitrate('enter', false)).toBe('pass') + expect(controller.arbitrate('tab', false)).toBe('pass') // Open with the only group still pending: nothing to pick yet. controller.track('/g', 2, { tier: 'plain' }, 1) expect(controller.arbitrate('enter', false)).toBe('pass') + expect(controller.arbitrate('tab', false)).toBe('pass') }) it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => { From a074e6131fd13dd88c7ddc0595904fb53b92ef1e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 31 Aug 2026 14:59:42 +0800 Subject: [PATCH 09/43] fix(ci): serial-windows notices timeout and generator store-scan cost render() loaded the workspace manifests once per external dependency name through workspaceLinkedManifest, an O(names x manifests) file read on the cold path; on the loaded self-hosted Windows host with coverage instrumentation the freshness spec crossed Vitest's default 5000ms budget and failed the serial-windows standby gate four times in a week. Load the manifests once in render() and thread the map through the collectors instead. The serial-windows lane also ran the coverage inventory at the strictest budget of any lane: add DSH_COVERAGE_TEST_TIMEOUT_MS=90000 to match the pull-request windows-coverage lane, pinned by ci-workflow.spec.ts. --- ...l-windows-notices-timeout-budget.i18n.yaml | 6 ++++ ...1-serial-windows-notices-timeout-budget.md | 30 +++++++++++++++++ ...erial-windows-notices-timeout-budget.zh.md | 30 +++++++++++++++++ ...ws-lane-hook-and-lefthook-budget.i18n.yaml | 4 +-- ...9-windows-lane-hook-and-lefthook-budget.md | 2 +- ...indows-lane-hook-and-lefthook-budget.zh.md | 2 +- .github/workflows/ci-master.yml | 1 + scripts/ci-workflow.spec.ts | 8 +++++ scripts/gen-third-party-notices.ts | 33 +++++++++++-------- 9 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.md create mode 100644 .agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.zh.md diff --git a/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.i18n.yaml b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.i18n.yaml new file mode 100644 index 0000000000..14665f78bc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.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/process/2026-08-31-serial-windows-notices-timeout-budget.md +2026-08-31-serial-windows-notices-timeout-budget.md: 184004122bf8050fa00038b7c57ae680748463f8 +2026-08-31-serial-windows-notices-timeout-budget.zh.md: 4fc00ed25cc24ef76e43a0099a362b8be0e59e3e diff --git a/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.md b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.md new file mode 100644 index 0000000000..184004122b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.md @@ -0,0 +1,30 @@ +# Agent Note: serial-windows notices timeout budget and generator store-scan cost + +Status: implemented + +English | [中文](2026-08-31-serial-windows-notices-timeout-budget.zh.md) + +## Problem + +The `serial / windows (self-hosted standby)` master lane failed its `test:coverage` gate four times in a week (runs 33333033178, 33311481884, 33352293522, 33353113100), always on the same case: `scripts/gen-third-party-notices.spec.ts > THIRD_PARTY_NOTICES.md > matches what the generator produces from the current manifests`, with `Error: Test timed out in 5000ms`. Measured test wall times on the shared Windows host were 4149–8853 ms against Vitest's default 5000 ms per-test budget. All other 26 cases in the file finished in 0–3 ms, and the passing run two hours later (33360033028) had the same code green. + +The lane runs the complete unsharded Windows gate inventory serially with `DSH_COVERAGE_MAX_WORKERS=1`, so `render()` regenerates `THIRD_PARTY_NOTICES.md` from the workspace manifests and the installed pnpm store on a host shared by 32 runners. The cold path is dominated by `workspaceLinkedManifest`, which re-ran `loadWorkspaceManifests()` — a glob plus reads and JSON-parses of every workspace `package.json` — once per cache-missing external dependency name: 130 names × 270 manifests ≈ 35k file operations, on top of a `.pnpm` store scan per name. Under v8 coverage instrumentation and shared-host I/O contention that crossed the 5 s default. + +The lane also had no `DSH_COVERAGE_TEST_TIMEOUT_MS`, unlike the pull-request `windows-coverage` lane ([ci.yml](../../../../.github/workflows/ci.yml)) which grants 90000 ms, so the serial reference ran the same coverage inventory at the strictest budget of any lane. + +## Decision + +Two changes: + +1. [scripts/gen-third-party-notices.ts](../../../../scripts/gen-third-party-notices.ts) loads the workspace manifests once in `render()` and threads the map through `collectNpmDeps` → `installedMetadata` → `installedManifest` → `workspaceLinkedManifest` instead of reloading it per external dependency name. The cold `render()` wall time on the same checkout fell from ~893 ms to ~86 ms with byte-identical output (verified by diffing the rendered documents before and after the change). + +2. [ci-master.yml](../../../../.github/workflows/ci-master.yml) `serial-windows` step "Run complete unsharded Windows gate inventory serially" gains `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the pull-request `windows-coverage` lane budget. This extends the per-test, expect.poll, and hook budget mechanism defined by [the Windows lane hook and Lefthook budget note](../testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) to a second lane; that note records which lanes set the env. `scripts/ci-workflow.spec.ts` pins this env with a `toMatchObject` assertion; removing the env turns the spec red (negative control exercised). + +## Alternatives considered + +- **Raise the lane budget only** - rejected as the sole fix: it would mask the O(names × manifests) reload for every lane that runs the generator, including the pre-commit hook and the standalone `--check` path. +- **Module-level cache for `loadWorkspaceManifests()`** - rejected in favor of explicit threading, which keeps the single-load contract visible at the call site and avoids a second hidden cache next to `workspaceLinkedManifestCache`. + +## Consequences + +The generator resolves installed metadata from one manifest load per `render()` call, and clears the name-keyed linked-manifest cache at the start of each call so the cache cannot outlive the map it was resolved from. The serial-windows lane runs the coverage inventory at the same 90000 ms per-test budget as the pull-request coverage lane. `THIRD_PARTY_NOTICES.md` bytes are unchanged; the freshness spec still compares `render()` against the committed document. diff --git a/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.zh.md b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.zh.md new file mode 100644 index 0000000000..4fc00ed25c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-31-serial-windows-notices-timeout-budget.zh.md @@ -0,0 +1,30 @@ +# Agent Note:serial-windows 的 notices 超时预算与 generator store 扫描成本 + +Status: implemented + +[English](2026-08-31-serial-windows-notices-timeout-budget.md) | 中文 + +## Problem + +`serial / windows (self-hosted standby)` master lane 一周内四次失败在 `test:coverage` gate(run 33333033178、33311481884、33352293522、33353113100),失败用例每次都相同:`scripts/gen-third-party-notices.spec.ts > THIRD_PARTY_NOTICES.md > matches what the generator produces from the current manifests`,报 `Error: Test timed out in 5000ms`。共享 Windows 主机上该用例实测 4149–8853 ms,超出 Vitest 默认的 5000 ms 单测预算。文件其余 26 个用例全部 0–3 ms 通过,两小时后的 passing run(33360033028)用同一份代码全绿。 + +该 lane 以 `DSH_COVERAGE_MAX_WORKERS=1` 串行跑完整的无分片 Windows gate 清单,`render()` 要从 workspace manifest 和已安装的 pnpm store 全量重生成 `THIRD_PARTY_NOTICES.md`,而主机被 32 个 runner 共享。冷路径的代价集中在 `workspaceLinkedManifest`:它对每个未缓存的外部依赖名重跑一遍 `loadWorkspaceManifests()`——glob 并读取、解析全部 workspace `package.json`——即 130 名 × 270 manifest ≈ 3.5 万次文件操作,另加每个名字一次 `.pnpm` store 扫描。叠加 v8 覆盖率插桩与共享主机 I/O 争抢后越过 5 秒默认值。 + +该 lane 还没有 `DSH_COVERAGE_TEST_TIMEOUT_MS`,而 pull-request 的 `windows-coverage` lane([ci.yml](../../../../.github/workflows/ci.yml))给的是 90000 ms——于是这条 serial 参考 lane 用全仓库最紧的预算跑同一份 coverage 清单。 + +## Decision + +两处改动: + +1. [scripts/gen-third-party-notices.ts](../../../../scripts/gen-third-party-notices.ts) 在 `render()` 里只加载一次 workspace manifest,把 map 沿 `collectNpmDeps` → `installedMetadata` → `installedManifest` → `workspaceLinkedManifest` 显式传递,不再按外部依赖名逐个重载。同一 checkout 下冷 `render()` 墙钟从约 893 ms 降到约 86 ms,输出逐字节一致(改动前后渲染结果 diff 验证)。 + +2. [ci-master.yml](../../../../.github/workflows/ci-master.yml) `serial-windows` 的 "Run complete unsharded Windows gate inventory serially" 步骤增加 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 pull-request `windows-coverage` lane 对齐。这是把 [Windows 覆盖率 lane 的 hook 预算与 Lefthook 套件预算 note](../testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md) 定义的 per-test、expect.poll 与 hook 预算机制扩展到第二个 lane;该 note 记录了哪些 lane 设置此 env。`scripts/ci-workflow.spec.ts` 用 `toMatchObject` 断言钉住该 env;删掉 env 会让 spec 变红(已做负例验证)。 + +## Alternatives considered + +- **只放宽 lane 预算** - 否决作为唯一修复:会掩盖所有运行 generator 的 lane 上的 O(名×manifest) 重载成本,包括 pre-commit hook 与独立 `--check` 路径。 +- **给 `loadWorkspaceManifests()` 加模块级缓存** - 否决,改用显式传递:把「单次加载」契约留在调用点可见,避免在 `workspaceLinkedManifestCache` 之外再加一层隐藏缓存。 + +## Consequences + +generator 每次 `render()` 调用只加载一次 manifest 来解析已安装元数据,并在调用开头清空按名字作键的 linked-manifest 缓存,使缓存不会活过它解析自的那份 map。serial-windows lane 与 pull-request coverage lane 一样按 90000 ms 单测预算跑 coverage 清单。`THIRD_PARTY_NOTICES.md` 字节不变;新鲜度 spec 仍把 `render()` 与已提交文档逐字节比较。 diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml index 172adc21af..f9baab437d 100644 --- a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.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/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md -2026-08-29-windows-lane-hook-and-lefthook-budget.md: 6886e3ad4958d20a88a66df6a9e02f5a60a36a6a -2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: 56c1e625d92f01e24f2268deb5d03040278da6af +2026-08-29-windows-lane-hook-and-lefthook-budget.md: 40ebba25e459abd6d9ae755f831e703aa826ae39 +2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md: c14be5da098920dbb7360c01bbb94f0bc22f700b diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md index 6886e3ad49..40ebba25e4 100644 --- a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md @@ -24,7 +24,7 @@ A `git` or `node` spawn spike on the shared-volume runners no longer decides eit Both budgets widen what counts as an acceptable duration, so a real slowdown into tens of seconds now passes where the previous ceilings would have caught it. That detection is traded away deliberately: those ceilings were firing on host contention rather than on regressions. -The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set, which today is the Windows coverage lane alone. Lanes that leave it unset keep every Vitest default, including the 10 s hook budget. +The hook change applies wherever `DSH_COVERAGE_TEST_TIMEOUT_MS` is set: the Windows coverage lane in [ci.yml](../../../../.github/workflows/ci.yml) and the `serial-windows` master standby in [ci-master.yml](../../../../.github/workflows/ci-master.yml) ([the serial-windows notices timeout note](../process/2026-08-31-serial-windows-notices-timeout-budget.md) records the second lane's adoption). Lanes that leave it unset keep every Vitest default, including the 10 s hook budget. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md index 56c1e625d9..c14be5da09 100644 --- a/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md +++ b/.agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.zh.md @@ -24,7 +24,7 @@ Lefthook 套件取 `{ timeout: 90_000 }`,与 [`.github/workflows/ci.yml`](../. 两份预算都放宽了「多长算可接受」,因此一个退化到几十秒的真实变慢现在会通过,而此前的上限会拦住它。这项检测能力是有意换掉的:那些上限触发的是宿主机争抢,不是回归。 -hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效,目前仅 Windows 覆盖率 lane 一处。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。 +hook 的改动在所有设置了 `DSH_COVERAGE_TEST_TIMEOUT_MS` 的地方生效:[ci.yml](../../../../.github/workflows/ci.yml) 的 Windows 覆盖率 lane,以及 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 的 `serial-windows` master standby([serial-windows notices 超时 note](../process/2026-08-31-serial-windows-notices-timeout-budget.zh.md) 记录了第二个 lane 的采用)。不设置它的 lane 保持全部 Vitest 默认值,包括 10 秒的 hook 预算。 ## 备选方案 diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index e16fd624d9..3721045072 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -208,6 +208,7 @@ jobs: shell: pwsh env: DSH_COVERAGE_MAX_WORKERS: '1' + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' DSH_GATE_CONCURRENCY: '1' DSH_PUBLINT_CONCURRENCY: '1' run: pnpm run check:ci:windows-complete diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e33df9aeb3..cfdb67cc02 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -201,6 +201,14 @@ describe('CI workflow', () => { expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('} else {') expect(serialInstall!.run.split('\n').map(line => line.trim())).toContain('pnpm install --frozen-lockfile') expect(serialInstall!.run).not.toContain('$cloneFlag') + // The unsharded reference runs the whole coverage inventory at the same + // per-test budget the PR coverage lane grants; the default 5000ms times + // out load-sensitive store scans (e.g. gen-third-party-notices). + const serialGate = serialSteps.find((step): step is Record & { env?: Record } => ( + isRecord(step) && step.name === 'Run complete unsharded Windows gate inventory serially' + )) + expect(serialGate).toBeDefined() + expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' }) // Aggregate: Wine and the required split native jobs are needed; // windows-coverage is temporarily non-blocking while Windows ACP diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index e076677055..561064b413 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -279,11 +279,13 @@ const workspaceLinkedManifestCache = new Map): VirtualManifest | undefined { if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name) - for (const [path, manifest] of loadWorkspaceManifests().manifests) { + for (const [path, manifest] of manifests) { if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json') if (!existsSync(linked)) continue @@ -296,8 +298,8 @@ function workspaceLinkedManifest(name: string): VirtualManifest | undefined { } /** Resolve one installed external package manifest from either pnpm store. */ -function installedManifest(name: string, expectedVersion?: string): VirtualManifest | undefined { - const linked = workspaceLinkedManifest(name) +function installedManifest(name: string, manifests: Map, expectedVersion?: string): VirtualManifest | undefined { + const linked = workspaceLinkedManifest(name, manifests) if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // Workspace-local link farms can expose a dependency that is not linked at @@ -320,9 +322,9 @@ function installedManifest(name: string, expectedVersion?: string): VirtualManif } /** License and repository URL for an installed external package, from the pnpm store. */ -function installedMetadata(name: string): { license: string; repo: string } { +function installedMetadata(name: string, manifests: Map): { license: string; repo: string } { const override = OVERRIDES[name] - const manifest = installedManifest(name) + const manifest = installedManifest(name, manifests) const license = override?.license ?? manifest?.license const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage const repo = override?.repo ?? normalizeRepo(rawRepo) @@ -332,8 +334,8 @@ function installedMetadata(name: string): { license: string; repo: string } { return { license, repo } } -function collectClaudeDistribution(): ClaudeDistribution { - const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE) +function collectClaudeDistribution(manifests: Map): ClaudeDistribution { + const manifest = installedManifest(CLAUDE_AGENT_SDK_PACKAGE, manifests) if (manifest === undefined) { throw new Error( `gen-third-party-notices: cannot resolve ${CLAUDE_AGENT_SDK_PACKAGE}; run \`pnpm install\`.`, @@ -342,7 +344,7 @@ function collectClaudeDistribution(): ClaudeDistribution { const distribution = claudeDistributionFromManifest(manifest) let installedPayloads = 0 for (const payload of distribution.payloads) { - const installed = installedManifest(payload.name, payload.version) + const installed = installedManifest(payload.name, manifests, payload.version) if (installed === undefined) continue installedPayloads += 1 if ( @@ -383,12 +385,11 @@ function normalizeRepo(raw: string | undefined): string | undefined { * by tooling, test infrastructure, the website, or the demo leaves — whatever * the declaring section is called — is development-only. */ -function collectNpmDeps(): ExternalDep[] { - const { manifests, names } = loadWorkspaceManifests() +function collectNpmDeps(manifests: Map, names: Set): ExternalDep[] { return [...tierExternalDeps(manifests, names)] .filter(([name]) => !FIRST_PARTY.has(name)) .sort(([a], [b]) => a.localeCompare(b)) - .map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime })) + .map(([name, runtime]) => ({ name, ...installedMetadata(name, manifests), runtime })) } /** @@ -692,7 +693,11 @@ ${rows.join('\n')} */ export function render(): string { verifyBuildTimePins() - const npm = collectNpmDeps() + // The linked-manifest cache is keyed by name only, so it must not outlive + // the manifests map it was resolved from; render() owns that single load. + workspaceLinkedManifestCache.clear() + const { manifests, names } = loadWorkspaceManifests() + const npm = collectNpmDeps(manifests, names) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) const vendored = collectVendored() @@ -701,7 +706,7 @@ export function render(): string { const claudeDistribution = runtimeDeps.some( dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, ) - ? collectClaudeDistribution() + ? collectClaudeDistribution(manifests) : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) // A copyleft license reaching a shipped surface is a distribution decision, From 8478de9b0e8c43948caa81953fb460911b342fd7 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 30 Aug 2026 17:33:17 +0800 Subject: [PATCH 10/43] perf(client): linearize inbox projection state --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 11 +- ...09-client-conversation-node-assembly.zh.md | 11 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/conversation-nodes/inbox.ts | 117 +++++++++++++----- ...nversation-node-definitions.client.spec.ts | 3 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/trajectory-message-definitions.ts | 88 +++++++++++-- 12 files changed, 188 insertions(+), 62 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 10370530e9..e41a7fcd43 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: e37f2ab3bdf9f3943cb1bcd193d5ea578a0b2c19 -2026-08-09-client-conversation-node-assembly.zh.md: be23b321b710f2f56e3e7af870ba554adb355cef +2026-08-09-client-conversation-node-assembly.md: 29ee0821638e6a594e53ce1410426c34f099b93d +2026-08-09-client-conversation-node-assembly.zh.md: 18c3df86c970cdf1d5d51e131956271ae049b628 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index e37f2ab3bd..29ee082163 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -18,6 +18,8 @@ Client Runtime provides a target-neutral Conversation Node assembly engine. Busi This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation. +Chat registers an Inbox Definition only for `next-step`, because message classification is its sole consumer; `next-turn` splices remain durable Session inputs but create no Chat Context. Chat and Trajectory each keep target-owned next-step state. Every insertion stores only message IDs in an immutable splice node. A successful claim materializes the pending chain once, replaces the previous claimed set with that batch, and lets later Contexts share the set until another claim. Historical Contexts therefore retain linear ID state instead of cumulative array and Set snapshots. + ### Responsibility layers | Layer | Durable responsibility | Explicitly does not own | @@ -257,8 +259,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec | Business / `kind` | Stable ID | Start Match | Update Matches | State and cross-Context reads | |---|---|---|---|---| -| Next-turn Inbox / `inbox-next-turn` | Splice Event seq | Each `agent/inbox/spliced` targeting next-turn | None | Apply the current splice to the pending/claimed instantaneous state from `reader.previous(ownKind)` | -| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Build the same per-instruction instantaneous state; Message reads its claimed set | +| Next-step Inbox / `inbox-next-step` | Splice Event seq | Each `agent/inbox/spliced` targeting next-step | None | Append message IDs to persistent splice state; materialize once per claim and expose the shared current claimed batch to Message | | Message / `input-message` | Message ID | Append-surface `user/message` | None | Use source for a context message, or read the nearest next-step Inbox to distinguish user from steering | | Request Prompt / `request-prompt` | Header Event seq | Each `request/header` | None | Read the preceding Request Prompt through Reader, retain the full prompt state, and classify system/tool changes | | Assistant / `assistant-step` | `turn:step` | `step/start` | Scalar or packed `assistant/chunk`, final `assistant/message`, and same-step Retry | Aggregate blocks, usage, first-token time, final evidence, and retry-hidden state, then publish same-key Step data | @@ -275,7 +276,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec | Business | `publication()` | Chat output | History and runtime behavior | |---|---|---|---| -| Inbox | `none` | No Node | Recompute instantaneous states along the Reader chain when prepend supplies earlier splices | +| Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context | | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key | | Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor | | Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation | @@ -288,7 +289,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec | Deliverables | Immediate by default | No Node | Tool settlement incrementally updates Turn data; the Turn Tail extension slot reads produced files | | Fallback | Immediate by default | `unknown` JSON row | Covers only append-surface Events; an ordinary business that claimed but has not rendered an Event does not duplicate it | -Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. +Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history. @@ -409,6 +410,8 @@ Separating State updates from publication cadence folds every live Assistant del Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates. +Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window. + The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal. `useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index be23b321b7..18c3df86c9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -18,6 +18,8 @@ Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务 本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 +Chat 只注册 `next-step` Inbox Definition,因为消息分类是其唯一消费方;`next-turn` splice 仍是持久 Session input,但不会创建 Chat Context。Chat 与 Trajectory 各自维护 target 专属 next-step state。每次插入只把消息 ID 写入不可变 splice 节点。成功 claim 时只 materialize 一次 pending 链,以当前批次替换上一个 claimed Set,并让后续 Context 共享该 Set,直到下一次 claim。历史 Context 因而只保留线性 ID state,不再保留累计数组和 Set 快照。 + ### 责任分层 | 层 | 长期职责 | 明确不负责 | @@ -257,8 +259,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 | 业务 / `kind` | 稳定 ID | start Match | update Matches | State 与跨 Context 读取 | |---|---|---|---|---| -| Next-turn Inbox / `inbox-next-turn` | splice Event seq | 每条目标为 next-turn 的 `agent/inbox/spliced` | 无 | 从 `reader.previous(ownKind)` 的 pending/claimed 瞬间态应用当前 splice | -| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 同样形成逐指令瞬间态,claimed 集合供 Message 读取 | +| Next-step Inbox / `inbox-next-step` | splice Event seq | 每条目标为 next-step 的 `agent/inbox/spliced` | 无 | 把消息 ID 追加到持久 splice state;每次 claim 只 materialize 一次,并向 Message 暴露共享的当前 claimed batch | | Message / `input-message` | message ID | append-surface `user/message` | 无 | 根据 source 生成 context message,或读取最近 next-step Inbox 判断 user/steering | | Request Prompt / `request-prompt` | header Event seq | 每条 `request/header` | 无 | 通过 Reader 读取前一条 Request Prompt,保留完整 prompt 状态,并判定 system/tool 变化 | | Assistant / `assistant-step` | `turn:step` | `step/start` | scalar 或 packed `assistant/chunk`、final `assistant/message`、同 step Retry | 聚合 blocks、usage、首 token 时间、final 和 retry 隐藏状态,并发布同 key Step data | @@ -275,7 +276,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 | 业务 | `publication()` | Chat 产物 | 历史分页与运行时行为 | |---|---|---|---| -| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算瞬间态 | +| Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context | | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | | Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 | | Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | @@ -288,7 +289,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 | Deliverables | 默认 immediate | 不生成 Node | Tool 结算增量更新所属 Turn data,Turn Tail 扩展槽读取 produced files | | Fallback | 默认 immediate | `unknown` JSON row | 只兜底 append surface,普通业务已认领但暂不可见时不会重复生成 | -Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。它通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。 +Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。 @@ -409,6 +410,8 @@ State 更新与发布频率分离后,Assistant 的每条 live delta 与每个 Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。 +Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。 + 代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。 `useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ea2377aa37..1e22b05602 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: 4f5e969197453ee030e2deb8a09f36d3327b5ed0 -README.zh.md: 47ed92366f6c9e0c5b54c8d5095d30c8561bad47 +README.md: 9fe462316e6ab6d664a43b9fc481e07b01eda093 +README.zh.md: bad8a9143cd0307cdc5edb3e66bb9984cd5ca2be diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 4f5e969197..9fe462316e 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -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`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +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`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 47ed92366f..bad8a9143c 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 ## 目录 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts index 7fd86cfbf0..e413a18766 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts @@ -2,68 +2,125 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InboxTarget } from '@deepseek-ai/dsh-agent/types' interface InboxIdentity { readonly id: string } interface InboxSplice { - readonly target: InboxTarget readonly start: number readonly removedCount?: number readonly inserted: readonly InboxIdentity[] readonly outcome?: 'canceled' } +interface PendingSnapshot { + readonly kind: 'snapshot' + readonly ids: readonly string[] +} + +interface PendingSplice { + readonly kind: 'splice' + readonly previous: PendingState + readonly start: number + readonly removedCount: number + readonly inserted: readonly string[] +} + +type PendingState = PendingSnapshot | PendingSplice + /** Cumulative state after one durable inbox splice. */ export interface InboxState { - readonly pending: readonly InboxIdentity[] + /** Persistent splice chain materialized only when a next-step batch is claimed. */ + readonly pending: PendingState + /** Message ids in the current claimed batch, shared until the next claim. */ readonly claimed: ReadonlySet } +const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } +const EMPTY_CLAIMED: ReadonlySet = new Set() + +function materializePending(state: PendingState): string[] { + const splices: PendingSplice[] = [] + let current = state + while (current.kind === 'splice') { + splices.push(current) + current = current.previous + } + const pending = [...current.ids] + for (let index = splices.length - 1; index >= 0; index--) { + const splice = splices[index] as PendingSplice + pending.splice(splice.start, splice.removedCount, ...splice.inserted) + } + return pending +} + +function withoutInserted( + claimed: ReadonlySet, + inserted: readonly string[], +): ReadonlySet { + let next: Set | undefined + for (const id of inserted) { + if (!claimed.has(id)) continue + next ??= new Set(claimed) + next.delete(id) + } + return next ?? claimed +} + function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, ): InboxState { - const pending = [...(previous?.state.pending ?? [])] - const claimed = new Set(previous?.state.claimed ?? []) - const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) - for (const identity of splice.inserted) claimed.delete(identity.id) - if (splice.target === 'next-step' && splice.outcome !== 'canceled') { - for (const identity of removed) claimed.add(identity.id) + const priorPending = previous?.state.pending ?? EMPTY_PENDING + const inserted = splice.inserted.map(identity => identity.id) + const removedCount = splice.removedCount ?? 0 + const priorClaimed = withoutInserted(previous?.state.claimed ?? EMPTY_CLAIMED, inserted) + if (removedCount > 0 && splice.outcome !== 'canceled') { + const pending = materializePending(priorPending) + const removed = pending.splice(splice.start, removedCount, ...inserted) + return { + pending: { kind: 'snapshot', ids: pending }, + claimed: new Set(removed), + } } - return { pending, claimed } -} - -function inboxDefinition(target: InboxTarget): ConversationNodeDefinition { - const kind = `inbox-${target}` return { - kind, - match: event => event.type === 'agent/inbox/spliced' - && event.data.target === target - ? { id: String(event.seq), role: 'start' } - : null, - start: (_context, match, reader) => { - if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`) - return applySplice(reader.previous(kind), match.event.data) + pending: { + kind: 'splice', + previous: priorPending, + start: splice.start, + removedCount, + inserted, }, - update: context => context.state, - publication: () => 'none', + claimed: priorClaimed, } } -/** Cumulative next-turn inbox splice Definition. */ -export const nextTurnInboxDefinition = inboxDefinition('next-turn') +const NEXT_STEP_INBOX_KIND = 'inbox-next-step' -/** Cumulative next-step inbox splice Definition used to classify steering. */ -export const nextStepInboxDefinition = inboxDefinition('next-step') +/** Cumulative next-step Inbox state used to classify steering. */ +export const nextStepInboxDefinition: ConversationNodeDefinition = { + kind: NEXT_STEP_INBOX_KIND, + match: (event) => { + if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') { + return { id: String(event.seq), role: 'start' } + } + return null + }, + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') { + throw new Error('inbox-next-step start requires agent/inbox/spliced') + } + return applySplice(reader.previous(NEXT_STEP_INBOX_KIND), match.event.data) + }, + update: context => context.state, + publication: () => 'none', +} /** - * Register the two durable Inbox-state contributions. + * Register the next-step Inbox state used by Chat message classification. * @param ctx - owning UI Conversation context. */ export function registerInboxConversationNodes(ctx: Context): void { - ctx.uiConversation.events.register(nextTurnInboxDefinition) ctx.uiConversation.events.register(nextStepInboxDefinition) } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 5315045237..7a2acd9a63 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -22,7 +22,7 @@ import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapsh import { commandDefinition } from '../src/client/conversation-nodes/command.ts' import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts' import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts' -import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' +import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../src/client/conversation-nodes/message.ts' import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client' import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts' @@ -37,7 +37,6 @@ import type { } from '../src/client/contract/chat-nodes.ts' const DEFINITIONS: readonly ConversationNodeDefinition[] = [ - nextTurnInboxDefinition, nextStepInboxDefinition, messageDefinition, requestPromptDefinition(inspectRequestPrompt), diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a5e2e32ac8..a39be8702e 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: cb11a8186f32761b6af208ae79196feff8f098d5 -README.zh.md: 5b02e965b3d4e041b7a405d210bace366551cecb +README.md: 73bacf0761e2427b0c2632ed274de68986058ebd +README.zh.md: 8672ff0787472b284ccb4639876f7fc3097af9e1 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index cb11a8186f..73bacf0761 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -43,7 +43,7 @@ A fixed Overview above the ledger projects real record start/duration timing fro
Implementation internals — click to expand -The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot. +The view is a pure projection: Trajectory-owned Definitions assemble business records from the shared Session window — including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records — so Trajectory neither reads nor changes the Chat conversation snapshot. Its steering classifier retains only next-step Inbox IDs through persistent splice state and shares each current claimed batch across later Contexts. ### Virtual rows diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5b02e965b3..8672ff0787 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。 +视图是纯投影:Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录——包括持久化的取消定稿前缀、只能从分片恢复的打断前缀与被打断的工具记录——因此 Trajectory 既不读取也不改变 Chat 会话快照。其 steering 分类器通过持久 splice state 只保留 next-step Inbox ID,并让后续 Context 共享当前 claimed batch。 ### 虚拟行 diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index d8b714197a..b854efc912 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -21,33 +21,97 @@ interface InboxSplice { readonly outcome?: 'canceled' } +interface PendingSnapshot { + readonly kind: 'snapshot' + readonly ids: readonly string[] +} + +interface PendingSplice { + readonly kind: 'splice' + readonly previous: PendingState + readonly start: number + readonly removedCount: number + readonly inserted: readonly string[] +} + +type PendingState = PendingSnapshot | PendingSplice + interface InboxState { - readonly pending: readonly InboxIdentity[] + /** Persistent splice chain materialized only when a next-step batch is claimed. */ + readonly pending: PendingState + /** Message ids in the current claimed batch, shared until the next claim. */ readonly claimed: ReadonlySet } type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } +const EMPTY_CLAIMED: ReadonlySet = new Set() + +function materializePending(state: PendingState): string[] { + const splices: PendingSplice[] = [] + let current = state + while (current.kind === 'splice') { + splices.push(current) + current = current.previous + } + const pending = [...current.ids] + for (let index = splices.length - 1; index >= 0; index--) { + const splice = splices[index] as PendingSplice + pending.splice(splice.start, splice.removedCount, ...splice.inserted) + } + return pending +} + +function withoutInserted( + claimed: ReadonlySet, + inserted: readonly string[], +): ReadonlySet { + let next: Set | undefined + for (const id of inserted) { + if (!claimed.has(id)) continue + next ??= new Set(claimed) + next.delete(id) + } + return next ?? claimed +} + function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, ): InboxState { - const pending = [...(previous?.state.pending ?? [])] - const claimed = new Set(previous?.state.claimed ?? []) - const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) - for (const identity of splice.inserted) claimed.delete(identity.id) - if (splice.outcome !== 'canceled') { - for (const identity of removed) claimed.add(identity.id) + const priorPending = previous?.state.pending ?? EMPTY_PENDING + const inserted = splice.inserted.map(identity => identity.id) + const removedCount = splice.removedCount ?? 0 + const priorClaimed = withoutInserted(previous?.state.claimed ?? EMPTY_CLAIMED, inserted) + if (removedCount > 0 && splice.outcome !== 'canceled') { + const pending = materializePending(priorPending) + const removed = pending.splice(splice.start, removedCount, ...inserted) + return { + pending: { kind: 'snapshot', ids: pending }, + claimed: new Set(removed), + } + } + return { + pending: { + kind: 'splice', + previous: priorPending, + start: splice.start, + removedCount, + inserted, + }, + claimed: priorClaimed, } - return { pending, claimed } } const trajectoryInboxDefinition: ConversationNodeDefinition = { kind: 'trajectory-inbox-next-step', - match: event => event.type === 'agent/inbox/spliced' - && event.data.target === 'next-step' - ? { id: String(event.seq), role: 'start' } - : null, + match: (event) => { + if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') { + return { id: String(event.seq), role: 'start' } + } + return null + }, start: (_context, match, reader) => { if (match.event.type !== 'agent/inbox/spliced') { throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced') From 61da6fbbe5ec6ec51155514ceaa340dfc0fee01f Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 30 Aug 2026 17:33:48 +0800 Subject: [PATCH 11/43] perf(web): defer tool body formatting until expansion --- ...ient-tool-presentation-ownership.i18n.yaml | 4 +-- ...8-08-client-tool-presentation-ownership.md | 12 +++++-- ...8-client-tool-presentation-ownership.zh.md | 12 +++++-- packages/client/ui-tool/README.i18n.yaml | 4 +-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../src/client/tool/components/ToolRow.tsx | 22 +++++++----- .../src/client/tool/models/tool-call-model.ts | 23 +++++++----- .../client/tool/toolviews/GenericToolCard.tsx | 2 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 18 ++++++---- .../tool/toolviews/file-mutation-row.tsx | 1 - .../src/client/tool/toolviews/read-row.tsx | 1 - .../src/client/tool/toolviews/search-row.tsx | 1 - .../src/client/tool/toolviews/todo-row.tsx | 2 +- .../src/client/tool/toolviews/web-row.tsx | 1 - .../tests/coverage-tails.client.spec.tsx | 2 +- .../ui-tool/tests/tool-row.client.spec.tsx | 36 ++++++++++++++----- 18 files changed, 98 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml index c32026ebc7..4c904a894f 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-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-08-client-tool-presentation-ownership.md -2026-08-08-client-tool-presentation-ownership.md: 1daad1559a6c8ef15fadb8e7c8dfeb2874ae3f9a -2026-08-08-client-tool-presentation-ownership.zh.md: f980db28e1174aa95b29defb8b0a36fc0ba4cf2e +2026-08-08-client-tool-presentation-ownership.md: bf8568150cc173f0dc46ba0a125ca9c784d18f2e +2026-08-08-client-tool-presentation-ownership.zh.md: 015a48a0499b84bf034dc905780eca0dcef99c10 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md index 1daad1559a..bf8568150c 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -22,6 +22,8 @@ A business Tool plugin receives one standard `ToolCallBlock`, identity, workspac The details panel is a second Tool presentation point, not the call-tree owner. `ui-conversation` locates the selected call and delegates its output body through `'conversation.details.tool'`; `ui-tool` reuses the card model, while the conversation fallback retains raw result text when the plugin is absent. +Generic row models retain the original argument string as `bodyRaw` and expose no preformatted body. `ToolRow` and the Bash fallback format it only while an expanded generic input section is visible; closing the row removes the formatted text, and rows rendering a structured card skip generic-body formatting. + ## Runtime and render path ```text @@ -33,6 +35,8 @@ Session Event window -> tool.call.toolview(entryKey = toolName) |- registered atomic view `- GenericToolCard fallback + |- collapsed or structured card: retain argsRaw only + `- expanded generic input: format argsRaw ``` ## Ownership boundary @@ -42,12 +46,12 @@ Session Event window | Client Runtime Conversation engine | Context identity, Location, history replay, view Node publication | Tool event meaning, call tree, Tool renderer | | `ui-conversation` Tool Definition | call/result pairing, Code Dispatch topology, running/settled/interrupted `ToolCallBlock`, Chat ordering anchor | Tool-name dispatch, card models, recursive React structure | | `ui-conversation` Chat view | keyed Node order, scroll anchors, selection, and host actions | Tool lifecycle, subcall composition, atomic Tool renderers | -| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, and details output | Session Event fold, Chat ordering | +| `ui-tool` | root/subcall recursive rendering, atomic keyed dispatch, fallback, card models, expansion-time argument formatting, and details output | Session Event fold, Chat ordering | | Business Tool plugin | atomic renderers for one or more wire Tool names | root/subcall placement, lifecycle pairing, Session projectors | ## Verification -`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, and concrete Tool cards. Assembled Web tests cover the path with both plugins loaded. +`ui-conversation` tests pin the Tool Definition's call/result pairing, Code Dispatch, interruption, and running-to-settled keyed identity without importing production `ui-tool` renderers. `ui-tool` tests mount the real conversation host and pin root/subcall recursion, keyed dispatch, Generic fallback, selection, details, concrete Tool cards, and expansion-only generic-body formatting. Assembled Web tests cover the path with both plugins loaded. ## Alternatives considered @@ -61,8 +65,12 @@ Session Event window **Let `ui-conversation` import `ui-tool` components directly.** Rejected: this would reverse the feature dependency and make Tool presentation mandatory. Slots preserve independent loading, lifecycle, and fallback behavior. +**Keep a preformatted body on the row model for compatibility.** Rejected: every collapsed row would retain a second full argument string, and the compatibility field would let future consumers restore eager formatting. The model exposes only `bodyRaw`, making expansion-time formatting the only generic path. + ## Consequences `ui-conversation` no longer depends on presentation for concrete Tool names, and root and subcalls cannot drift onto different dispatch paths. Business packages can independently own atomic Tool renderers; if `ui-tool` is absent, Conversation data assembly remains valid, Chat Nodes use the generic fallback, and details retain raw results. +Collapsed Tool rows retain the existing `argsRaw` reference without a pretty-printed copy or its formatting call. Expanding a generic input performs that work for the visible row, and closing it permits the derived text to be collected; repeated expansion trades bounded recomputation for lower retained memory. + The cost is an explicit dependency from `ui-tool` on the business Node slot and locale namespace declared by conversation, plus one Tool-specific child slot. Tool Definition remains in `ui-conversation` because this change does not split packages; it can later move through the Conversation registry seam without changing the presentation ownership recorded here. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md index f980db28e1..015a48a049 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -22,6 +22,8 @@ Conversation 数据组装遵循后续的 [Conversation 业务节点决策](2026- details panel 是第二个工具展示点,但不是调用树所有者。`ui-conversation` 定位 selected call,并通过 `'conversation.details.tool'` 委托 output body;`ui-tool` 复用 card model,插件缺席时 conversation fallback 保留 raw result text。 +Generic row model 保留原始参数字符串 `bodyRaw`,不暴露预格式化 body。`ToolRow` 与 Bash fallback 只在展开后的 generic input section 可见时格式化该字符串;收起行会移除格式化文本,渲染结构化卡片的行则跳过 generic body 格式化。 + ## 运行时与渲染路径 ```text @@ -33,6 +35,8 @@ Session Event window -> tool.call.toolview(entryKey = toolName) |- registered atomic view `- GenericToolCard fallback + |- collapsed or structured card: retain argsRaw only + `- expanded generic input: format argsRaw ``` ## 所有权边界 @@ -42,12 +46,12 @@ Session Event window | Client 运行时 Conversation engine | 上下文 identity、Location、历史回放、view Node 发布 | 工具事件含义、调用树、工具 renderer | | `ui-conversation` 工具 Definition | call/result 配对、Code Dispatch 拓扑、running/settled/interrupted `ToolCallBlock`、Chat 排序 anchor | 工具名称分发、card model、递归 React 结构 | | `ui-conversation` Chat view | keyed Node 顺序、scroll anchor、selection 与宿主动作 | 工具 lifecycle、subcall 组合、原子工具 renderer | -| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model 与 details output | 会话事件 fold、Chat 排序 | +| `ui-tool` | root/subcall 递归渲染、原子 keyed dispatch、fallback、card model、展开时参数格式化与 details output | 会话事件 fold、Chat 排序 | | 业务工具插件 | 一个或多个 wire 工具名称的原子 renderer | root/subcall 位置、生命周期配对、会话 projector | ## 验证 -`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details 和具体工具 card。组装后的 Web 测试覆盖两个插件共同装载的路径。 +`ui-conversation` 测试固定工具 Definition 的 call/result 配对、Code Dispatch、interruption 和 running-to-settled keyed identity,不导入 `ui-tool` 的生产 renderer。`ui-tool` 测试挂载真实 conversation 宿主,固定 root/subcall 递归、keyed dispatch、Generic fallback、selection、details、具体工具 card 与只在展开时执行的 generic body 格式化。组装后的 Web 测试覆盖两个插件共同装载的路径。 ## 考虑过的替代方案 @@ -61,8 +65,12 @@ Session Event window **让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转功能依赖并把工具展示变成必选能力。slot 保留独立装载、生命周期和 fallback。 +**为兼容性在 row model 上保留预格式化 body。** 拒绝:每个折叠行都会保留第二份完整参数字符串,而且兼容字段会让后续消费方恢复 eager 格式化。model 只暴露 `bodyRaw`,使展开时格式化成为唯一 generic 路径。 + ## 后果 `ui-conversation` 不再依赖工具名称对应的业务展示,root 与 subcall 也不会漂移到不同分发路径。业务包可以独立拥有原子工具 renderer;`ui-tool` 缺席时,Conversation 数据组装仍然成立,Chat Node 使用通用 fallback,details 保留 raw result。 +折叠的工具行只保留既有 `argsRaw` 引用,不创建 pretty-print 副本,也不执行对应的格式化调用。展开 generic input 时才为当前可见行完成这项工作,收起后派生文本可以被回收;重复展开以有界重算换取更低的常驻内存。 + 代价是 `ui-tool` 明确依赖 conversation 声明的业务 Node slot 和 locale namespace,并拥有一个工具专属子 slot。工具 Definition 暂时位于 `ui-conversation`,因为本次没有拆包;它以后可以沿 Conversation 注册表 seam 移动,而不会改变本记录规定的展示所有权。 diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 5cfd56493f..72b50c937d 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100 -README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa +README.md: 895274748d8ee5b7bece16e5b76836966cc52663 +README.zh.md: f4cf11cabdb94f45c9b11e7f7538ad5042a791e6 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 773a93801e..895274748d 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Generic rows retain the original `argsRaw` reference and format their input body only while it is expanded; structured cards skip generic-body formatting. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 88df08d7b5..f4cf11cabd 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。Generic row 保留原始 `argsRaw` 引用,只在展开期间格式化 input body;结构化卡片跳过 generic body 格式化。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index ab38bc9dbc..06029777ae 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -15,7 +15,9 @@ import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' -import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' +import { + formatToolBody, type ToolRowState, type ToolRowVariant, +} from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' import { AskQuestionCard } from './AskQuestionCard.tsx' import css from './ToolRow.module.css' @@ -36,8 +38,8 @@ export interface ToolRowProps { * error row, whose collapsed summary is the failure line instead. */ summarySuffix?: string | null | undefined - /** Expanded-body input text; null = no input section. */ - body: string | null + /** Original argument JSON formatted only while the row is expanded. */ + bodyRaw?: string | null | undefined /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ @@ -94,7 +96,7 @@ export function ToolRow({ title, summary, summarySuffix, - body, + bodyRaw, output, askQuestion, errorSummary, @@ -124,8 +126,12 @@ export function ToolRow({ const askQuestionBody = askQuestion ?? null const outputText = output ?? null const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody - const expandable = body !== null || outputText !== null || card !== null + const expandable = bodyRaw != null || outputText !== null || card !== null const open = expanded && expandable + const bodyText = useMemo( + () => open && card === null && bodyRaw != null ? formatToolBody(variant, bodyRaw) : null, + [bodyRaw, card, open, variant], + ) const status = stateStatus(state, t) // A failure must replace, not supplement, the normal summary. const failureLine = state === 'error' ? errorSummary ?? null : null @@ -156,7 +162,7 @@ export function ToolRow({ } // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. - const cardBody = variant === 'code' ? null : body + const cardBody = variant === 'code' ? null : bodyText return (
{status !== null && {status}} @@ -235,9 +241,9 @@ export function ToolRow({ ? : ( <> - {variant === 'code' && body !== null && ( + {variant === 'code' && bodyText !== null && (
- +
)} {(cardBody !== null || outputText !== null) && ( diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index a95861ea01..c0dd19358d 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -1,9 +1,9 @@ /** * Pure row-model derivation for tool summary rows: variant classification, - * one-line summary, expanded-body text, and flattened result output from the - * frozen call slice. Input material comes from the call ARGUMENTS; output and - * error material from the settled result node. A supported terminal call gets - * its expanded body from `terminalCardModel` instead. + * one-line summary, expansion-time body input, and flattened result output + * from the frozen call slice. Input material comes from the call ARGUMENTS; + * output and error material from the settled result node. A supported terminal + * call gets its expanded body from `terminalCardModel` instead. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer @@ -92,8 +92,8 @@ export interface ToolRowModel { * relative values against the session cwd before opening. */ filePath: string | undefined - /** Expanded-body input text (pretty args); null = no input section. */ - body: string | null + /** Original argument JSON retained for expansion-time body formatting. */ + bodyRaw: string | null /** Flattened result text ({@link resultText}); null while running or when the result carries no text. */ output: string | null /** First line of the result text on an error row; null for every other state. */ @@ -196,7 +196,13 @@ function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | unde return picked === undefined ? undefined : firstLine(picked) } -function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { +/** + * Format one argument payload when its generic input body becomes visible. + * @param variant - row presentation selected for the Tool name. + * @param argsRaw - original argument JSON or incomplete raw text. + * @returns display body, or null for empty input. + */ +export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string | null { if (argsRaw === '') return null const parsed = parseArgs(argsRaw) if (parsed === undefined) return argsRaw @@ -238,12 +244,13 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin // would erase the collapsed error row's summary slot. const output = done ? (resultText(block) || null) : null const errorSummary = state === 'error' && output !== null ? firstLine(output) : null + const bodyRaw = argsRaw === '' ? null : argsRaw return { variant, titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant], summary, filePath: deriveFilePath(variant, argsRaw), - body: deriveBody(variant, argsRaw), + bodyRaw, output, errorSummary, state, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index 41b13e20d6..726395d00c 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -51,7 +51,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is // single-file AND carries a card, so the card expands under the path link. - body={singleFile ? null : model.body} + bodyRaw={singleFile ? null : model.bodyRaw} output={model.output} errorSummary={model.errorSummary} terminal={terminal} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 4775686284..2a9178eb12 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -186,7 +186,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr icon={} title={t('ask.rowTitle')} summary={summary} - body={transcript === null ? model.body : null} + bodyRaw={transcript === null ? model.bodyRaw : null} output={transcript === null ? model.output : null} askQuestion={transcript} state={state} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 9490d92ec5..c291a733a1 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -1,4 +1,4 @@ -import { useState, type KeyboardEvent } from 'react' +import { useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { @@ -13,7 +13,7 @@ import { terminalCardModel, terminalFailed, } from '../models/terminal-card-model.ts' -import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts' +import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts' import { CONVERSATION_NS as NS } from '../../locale.ts' import css from './bash-sample.module.css' @@ -58,9 +58,15 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: // body; background acknowledgements and malformed calls remain collapsed. const genericBody = terminal === null && (model.state === 'error' || isSettledPersistentShellCall(block)) - && (model.body !== null || model.output !== null) + && (model.bodyRaw !== null || model.output !== null) const expandable = terminal !== null || genericBody const open = expanded && expandable + const body = useMemo( + () => open && genericBody && model.bodyRaw !== null + ? formatToolBody(model.variant, model.bodyRaw) + : null, + [genericBody, model.bodyRaw, model.variant, open], + ) const failureLine = model.state === 'error' ? model.errorSummary : null const toggleExpand = () => { setExpanded(v => !v) @@ -115,13 +121,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: ) : (
- {model.body !== null && ( + {body !== null && (
{t('row.input')} - {model.body} + {body}
)} - {model.body !== null && model.output !== null && ( + {body !== null && model.output !== null && ( )} {model.output !== null && ( diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 551bcd8e30..dafc8c0697 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -23,7 +23,6 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, icon={} title={t(model.titleKey)} summary={model.summary} - body={null} output={model.output} errorSummary={model.errorSummary} diff={diff} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index df3060c4e5..6ef6cd4bc5 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -23,7 +23,6 @@ export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: Re icon={} title={t(model.titleKey)} summary={model.summary} - body={null} output={model.output} errorSummary={model.errorSummary} read={read} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 4bb2617d57..d3ce18e976 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -28,7 +28,6 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { ? SEARCH_TITLE_KEYS.grep : toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)} summary={model.summary} - body={null} // ToolRow ignores output when a structured card is present; otherwise it // preserves the generic fallback for errors and legacy results. output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index ef4deee981..7493066722 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -58,7 +58,7 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { title={t('todo.rowTitle')} summary={summary.text} summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null} - body={model.body} + bodyRaw={model.bodyRaw} output={model.output} errorSummary={model.errorSummary} state={model.state} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 89cc20df83..6a739b7540 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -29,7 +29,6 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) { ? WEB_TITLE_KEYS.web_search : toolName === 'web_fetch' ? WEB_TITLE_KEYS.web_fetch : model.titleKey)} summary={model.summary} - body={null} output={model.output} errorSummary={model.errorSummary} web={web} diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 1077ea8ada..48703a812a 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -45,7 +45,7 @@ function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps { describe('Tool presentation tails', () => { it('ToolRow stopped state renders the warning dot in the leading slot', () => { const view = render( - } title="Bash" summary="s" body={null} state="stopped" />, + } title="Bash" summary="s" state="stopped" />, ) expect(view.queryByTestId('icon')).toBeNull() expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d295259908..1dd8118b3d 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -5,13 +5,16 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' +import { + classifyTool, formatToolBody, resultText, toolRowModel, +} from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(() => { cleanup() + vi.restoreAllMocks() }) const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) @@ -163,14 +166,17 @@ describe('tool-call-model', () => { }) it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { - expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') - expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') - expect(toolRowModel('bash', running({ argsRaw: '' })).body).toBeNull() - expect(toolRowModel('bash', result({ call: null })).body).toBeNull() + expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: '{"a":1}' })).bodyRaw ?? '')) + .toBe('{\n "a": 1\n}') + expect(formatToolBody('bash', toolRowModel('bash', running({ argsRaw: 'raw' })).bodyRaw ?? '')) + .toBe('raw') + expect(toolRowModel('bash', running({ argsRaw: '' })).bodyRaw).toBeNull() + expect(toolRowModel('bash', result({ call: null })).bodyRaw).toBeNull() }) it('a code row with an empty program falls back to the args JSON envelope', () => { - expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body) + const model = toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })) + expect(formatToolBody(model.variant, model.bodyRaw ?? '')) .toBe('{\n "code": ""\n}') }) @@ -228,7 +234,7 @@ describe('ToolRow', () => { const rowProps = { t, variant: 'bash' as const, icon: , title: 'Bash', - summary: 'List files', body: '{\n "a": 1\n}', state: 'ok' as const, + summary: 'List files', bodyRaw: '{"a":1}', state: 'ok' as const, } it('renders leading icon, title and summary while collapsed', () => { @@ -252,6 +258,18 @@ describe('ToolRow', () => { expect(view.getByText('List files')).toBeTruthy() }) + it('formats the argument body only while expanding it', () => { + const stringify = vi.spyOn(JSON, 'stringify') + const view = render() + expect(stringify).not.toHaveBeenCalled() + + fireEvent.click(view.getByRole('button')) + expect(stringify).toHaveBeenCalledTimes(1) + + fireEvent.click(view.getByRole('button')) + expect(stringify).toHaveBeenCalledTimes(1) + }) + it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => { const runningView = render() expect(runningView.queryByTestId('tool-icon')).not.toBeNull() @@ -264,7 +282,7 @@ describe('ToolRow', () => { }) it('non-expandable rows render a passive leading slot and no row button', () => { - const view = render() + const view = render() expect(view.queryByRole('button')).toBeNull() expect(view.container.querySelector('[aria-expanded]')).toBeNull() expect(view.queryByTestId('tool-icon')).not.toBeNull() @@ -391,7 +409,7 @@ describe('ToolRow', () => { expect(inputOnly.getByText('输入')).toBeTruthy() expect(inputOnly.queryByText('输出')).toBeNull() cleanup() - const outputOnly = render() + const outputOnly = render() fireEvent.click(outputOnly.getByRole('button')) expect(outputOnly.queryByText('输入')).toBeNull() expect(outputOnly.getByText('输出')).toBeTruthy() From b4527bedc7ad2eb027d8b24ca485a107ca9c00b1 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 30 Aug 2026 19:53:42 +0800 Subject: [PATCH 12/43] fix(client): pin inbox claim semantics --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 +- ...09-client-conversation-node-assembly.zh.md | 2 +- ...ry-conversation-context-assembly.i18n.yaml | 4 +- ...rajectory-conversation-context-assembly.md | 2 +- ...ectory-conversation-context-assembly.zh.md | 2 +- .../src/client/conversation-nodes/inbox.ts | 26 +++++--- .../src/client/conversation-nodes/message.ts | 3 +- ...nversation-node-definitions.client.spec.ts | 57 ++++++++++++++++ .../ui-tool/tests/tool-row.client.spec.tsx | 16 ++++- .../client/trajectory-message-definitions.ts | 26 +++++--- .../conversation-definitions.client.spec.ts | 66 +++++++++++++++++++ packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- .../tests/contract-regressions.spec.ts | 49 ++++++++++++++ 16 files changed, 233 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index e41a7fcd43..d0085672d5 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 29ee0821638e6a594e53ce1410426c34f099b93d -2026-08-09-client-conversation-node-assembly.zh.md: 18c3df86c970cdf1d5d51e131956271ae049b628 +2026-08-09-client-conversation-node-assembly.md: 4866f228052633af32a882dd4c96dd28e06851b5 +2026-08-09-client-conversation-node-assembly.zh.md: b50762b70d14c764f8ccf5d803993f5801ae755f diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 29ee082163..4866f22805 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -18,7 +18,7 @@ Client Runtime provides a target-neutral Conversation Node assembly engine. Busi This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation. -Chat registers an Inbox Definition only for `next-step`, because message classification is its sole consumer; `next-turn` splices remain durable Session inputs but create no Chat Context. Chat and Trajectory each keep target-owned next-step state. Every insertion stores only message IDs in an immutable splice node. A successful claim materializes the pending chain once, replaces the previous claimed set with that batch, and lets later Contexts share the set until another claim. Historical Contexts therefore retain linear ID state instead of cumulative array and Set snapshots. +Chat registers an Inbox Definition only for `next-step`, because message classification is its sole consumer; `next-turn` splices remain durable Session inputs but create no Chat Context. Chat and Trajectory each keep target-owned next-step state. Every insertion stores only message IDs in an immutable splice node. A successful claim materializes the pending chain once, replaces the previous claimed set with that batch, and lets later Contexts share the set until another claim. The AgentLoop appends every message admitted from that claim before it can claim another batch; a rejected claim appends no `user/message`, so later classification needs only the current batch. Historical Contexts therefore retain linear ID state instead of cumulative array and Set snapshots. ### Responsibility layers diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 18c3df86c9..b50762b70d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -18,7 +18,7 @@ Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务 本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 -Chat 只注册 `next-step` Inbox Definition,因为消息分类是其唯一消费方;`next-turn` splice 仍是持久 Session input,但不会创建 Chat Context。Chat 与 Trajectory 各自维护 target 专属 next-step state。每次插入只把消息 ID 写入不可变 splice 节点。成功 claim 时只 materialize 一次 pending 链,以当前批次替换上一个 claimed Set,并让后续 Context 共享该 Set,直到下一次 claim。历史 Context 因而只保留线性 ID state,不再保留累计数组和 Set 快照。 +Chat 只注册 `next-step` Inbox Definition,因为消息分类是其唯一消费方;`next-turn` splice 仍是持久 Session input,但不会创建 Chat Context。Chat 与 Trajectory 各自维护 target 专属 next-step state。每次插入只把消息 ID 写入不可变 splice 节点。成功 claim 时只 materialize 一次 pending 链,以当前批次替换上一个 claimed Set,并让后续 Context 共享该 Set,直到下一次 claim。AgentLoop 会在领取下一批消息之前追加当前 claim 接纳的全部消息;被拒绝的 claim 不追加 `user/message`,因此后续分类只需当前批次。历史 Context 因而只保留线性 ID state,不再保留累计数组和 Set 快照。 ### 责任分层 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml index 9af53e60e4..31a204685d 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.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-11-trajectory-conversation-context-assembly.md -2026-08-11-trajectory-conversation-context-assembly.md: 7d0aea2fc09f0f04bd5de923bee15c42a773489a -2026-08-11-trajectory-conversation-context-assembly.zh.md: 1909243da02093de1a5f1d0857a11d3742258492 +2026-08-11-trajectory-conversation-context-assembly.md: ad4bff77b87649a6e8b208aa6a55575c1a4e23f0 +2026-08-11-trajectory-conversation-context-assembly.zh.md: e7bd90dce00a8bd8a23eed8d076c5350df5d7c15 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md index 7d0aea2fc0..ad4bff77b8 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md @@ -40,7 +40,7 @@ Assistant chunks update only their `turn:step` Context. Content-bearing chunks r Trajectory reconstructs steering from durable inbox history, using the same identity rule as the [Chat steering decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) without sharing Chat's final Node. -Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, applies the splice, and stores the pending identities plus the cumulative set of claimed message IDs. A later user-origin `user/message` reads the nearest earlier inbox Context: a claimed ID produces a Steering Node, while every other user-origin message produces an ordinary User Node. +Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, appends the splice to persistent pending-ID state, and materializes that state only when a claim replaces the current claimed batch. The AgentLoop appends every admitted message from one claim before it can claim another batch; a rejected claim appends no `user/message`. A later user-origin `user/message` reads the nearest earlier inbox Context: an ID in the current claim produces a Steering Node, while every other user-origin message produces an ordinary User Node. A Reader miss while older history remains records a window-gap dependency. When prepend supplies the missing predecessor, the Assembler replays the affected inbox chain and message Contexts in forward Event order. Historical page direction therefore cannot permanently misclassify a message. diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md index 1909243da0..e7bd90dce0 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md @@ -40,7 +40,7 @@ Assistant chunk 只更新对应的 `turn:step` Context。带内容的 chunk 请 Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决策](../feature/2026-08-04-web-context-source-and-steer-marks.zh.md)相同的标识规则,但不共享 Chat 的最终 Node。 -每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,应用 splice,并存储待处理标识以及累计的已领取 message ID 集合。后续用户来源的 `user/message` 读取最近的前序 inbox Context:已领取的 ID 生成 Steering Node,其余用户来源消息生成普通 User Node。 +每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,把 splice 追加到持久的 pending ID state,并只在 claim 时 materialize 该 state、替换当前 claimed batch。AgentLoop 会在领取下一批消息之前追加当前 claim 接纳的全部消息;被拒绝的 claim 不追加 `user/message`。后续用户来源的 `user/message` 读取最近的前序 inbox Context:ID 属于当前 claim 时生成 Steering Node,其余用户来源消息生成普通 User Node。 仍有更早历史时,Reader miss 会记录 window-gap 依赖。prepend 补齐缺失的前驱后,Assembler 按 Event 正序重放受影响的 inbox chain 与 message Context。因此,历史分页方向不会永久错误分类消息。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts index e413a18766..d2a94dd72d 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts @@ -29,16 +29,16 @@ interface PendingSplice { type PendingState = PendingSnapshot | PendingSplice -/** Cumulative state after one durable inbox splice. */ +/** Persistent next-step state after one durable Inbox splice. */ export interface InboxState { /** Persistent splice chain materialized only when a next-step batch is claimed. */ readonly pending: PendingState - /** Message ids in the current claimed batch, shared until the next claim. */ - readonly claimed: ReadonlySet + /** Message ids in the current claim, shared until the next claim. */ + readonly currentClaimed: ReadonlySet } const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } -const EMPTY_CLAIMED: ReadonlySet = new Set() +const EMPTY_CURRENT_CLAIMED: ReadonlySet = new Set() function materializePending(state: PendingState): string[] { const splices: PendingSplice[] = [] @@ -48,8 +48,7 @@ function materializePending(state: PendingState): string[] { current = current.previous } const pending = [...current.ids] - for (let index = splices.length - 1; index >= 0; index--) { - const splice = splices[index] as PendingSplice + for (const splice of splices.reverse()) { pending.splice(splice.start, splice.removedCount, ...splice.inserted) } return pending @@ -68,6 +67,12 @@ function withoutInserted( return next ?? claimed } +/** + * Apply one next-step splice under the AgentLoop's durable event ordering. + * An entered claim logs its complete message batch before another claim; a + * rejected claim logs no messages, so only the current claim can classify a + * later `user/message`. + */ function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, @@ -75,15 +80,18 @@ function applySplice( const priorPending = previous?.state.pending ?? EMPTY_PENDING const inserted = splice.inserted.map(identity => identity.id) const removedCount = splice.removedCount ?? 0 - const priorClaimed = withoutInserted(previous?.state.claimed ?? EMPTY_CLAIMED, inserted) if (removedCount > 0 && splice.outcome !== 'canceled') { const pending = materializePending(priorPending) const removed = pending.splice(splice.start, removedCount, ...inserted) return { pending: { kind: 'snapshot', ids: pending }, - claimed: new Set(removed), + currentClaimed: new Set(removed), } } + const currentClaimed = withoutInserted( + previous?.state.currentClaimed ?? EMPTY_CURRENT_CLAIMED, + inserted, + ) return { pending: { kind: 'splice', @@ -92,7 +100,7 @@ function applySplice( removedCount, inserted, }, - claimed: priorClaimed, + currentClaimed, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 9611a36cf8..1927a468c5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -58,7 +58,8 @@ export const messageDefinition: ConversationNodeDefinition = { form: contextForm(event.data.source), } } - const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + const claimed = reader.previous('inbox-next-step') + ?.state.currentClaimed.has(String(event.data.id)) === true return claimed ? { kind: 'steering', diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 7a2acd9a63..2be5b239df 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -418,6 +418,63 @@ describe('built-in conversation node Definitions', () => { ]) }) + it('replays pending splice chains and scopes steering to the current claim', () => { + const first = textMessage('claim-first', 'first') + const second = textMessage('claim-second', 'second') + const canceled = textMessage('claim-canceled', 'canceled') + const requeued = textMessage('claim-requeued', 'requeued') + const later = textMessage('claim-later', 'later') + const current = snapshot(assembler([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [first], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 1, inserted: [canceled], + }), + at(3, 'agent/inbox/spliced', { + target: 'next-step', start: 1, inserted: [second], + }), + at(4, 'agent/inbox/spliced', { + target: 'next-step', start: 2, removedCount: 1, inserted: [], outcome: 'canceled', + }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 2, inserted: [], + }), + at(6, 'user/message', first, { surfaceOp: 'append' }), + at(7, 'user/message', second, { surfaceOp: 'append' }), + at(8, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [requeued], + }), + at(9, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(10, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [requeued], + }), + at(11, 'user/message', requeued, { surfaceOp: 'append' }), + at(12, 'user/message', canceled, { surfaceOp: 'append' }), + at(13, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', + }), + at(14, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [later], + }), + at(15, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(16, 'user/message', later, { surfaceOp: 'append' }), + ])) + + expect(current.order.map(key => current.nodes.get(key)).filter(node => + node?.kind === 'user' || node?.kind === 'steering')).toMatchObject([ + { kind: 'steering', data: { seq: 6 } }, + { kind: 'steering', data: { seq: 7 } }, + { kind: 'user', data: { seq: 11 } }, + { kind: 'user', data: { seq: 12 } }, + { kind: 'steering', data: { seq: 16 } }, + ]) + }) + it('orders a command-started Turn first steering before its process control', () => { const steering = textMessage('command-task', 'plan this change') const value = assembler([ diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 1dd8118b3d..7065f9e5b1 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -260,14 +260,24 @@ describe('ToolRow', () => { it('formats the argument body only while expanding it', () => { const stringify = vi.spyOn(JSON, 'stringify') + const bodyFormatCalls = () => stringify.mock.calls.filter( + ([value, replacer, space]) => typeof value === 'object' + && value !== null + && 'a' in value + && value.a === 1 + && replacer === null + && space === 2, + ).length const view = render() - expect(stringify).not.toHaveBeenCalled() + expect(bodyFormatCalls()).toBe(0) fireEvent.click(view.getByRole('button')) - expect(stringify).toHaveBeenCalledTimes(1) + expect(bodyFormatCalls()).toBe(1) + expect(view.getByText(/"a": 1/)).toBeTruthy() fireEvent.click(view.getByRole('button')) - expect(stringify).toHaveBeenCalledTimes(1) + expect(bodyFormatCalls()).toBe(1) + expect(view.queryByText(/"a": 1/)).toBeNull() }) it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => { diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index b854efc912..deb4097406 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -39,14 +39,14 @@ type PendingState = PendingSnapshot | PendingSplice interface InboxState { /** Persistent splice chain materialized only when a next-step batch is claimed. */ readonly pending: PendingState - /** Message ids in the current claimed batch, shared until the next claim. */ - readonly claimed: ReadonlySet + /** Message ids in the current claim, shared until the next claim. */ + readonly currentClaimed: ReadonlySet } type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } -const EMPTY_CLAIMED: ReadonlySet = new Set() +const EMPTY_CURRENT_CLAIMED: ReadonlySet = new Set() function materializePending(state: PendingState): string[] { const splices: PendingSplice[] = [] @@ -56,8 +56,7 @@ function materializePending(state: PendingState): string[] { current = current.previous } const pending = [...current.ids] - for (let index = splices.length - 1; index >= 0; index--) { - const splice = splices[index] as PendingSplice + for (const splice of splices.reverse()) { pending.splice(splice.start, splice.removedCount, ...splice.inserted) } return pending @@ -76,6 +75,12 @@ function withoutInserted( return next ?? claimed } +/** + * Apply one next-step splice under the AgentLoop's durable event ordering. + * An entered claim logs its complete message batch before another claim; a + * rejected claim logs no messages, so only the current claim can classify a + * later `user/message`. + */ function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, @@ -83,15 +88,18 @@ function applySplice( const priorPending = previous?.state.pending ?? EMPTY_PENDING const inserted = splice.inserted.map(identity => identity.id) const removedCount = splice.removedCount ?? 0 - const priorClaimed = withoutInserted(previous?.state.claimed ?? EMPTY_CLAIMED, inserted) if (removedCount > 0 && splice.outcome !== 'canceled') { const pending = materializePending(priorPending) const removed = pending.splice(splice.start, removedCount, ...inserted) return { pending: { kind: 'snapshot', ids: pending }, - claimed: new Set(removed), + currentClaimed: new Set(removed), } } + const currentClaimed = withoutInserted( + previous?.state.currentClaimed ?? EMPTY_CURRENT_CLAIMED, + inserted, + ) return { pending: { kind: 'splice', @@ -100,7 +108,7 @@ function applySplice( removedCount, inserted, }, - claimed: priorClaimed, + currentClaimed, } } @@ -148,7 +156,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { } } const claimed = reader.previous('trajectory-inbox-next-step') - ?.state.claimed.has(String(event.data.id)) === true + ?.state.currentClaimed.has(String(event.data.id)) === true return claimed ? { kind: 'steering', diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index bd7623142e..9b1a243a52 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -508,4 +508,70 @@ describe('Trajectory conversation Definitions', () => { ? request.promptChange?.kind : undefined)).toEqual(['initial', undefined]) }) + + it('replays pending splice chains and scopes steering to the current claim', () => { + const message = (id: string, text: string) => ({ + id, + role: 'user', + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }) + const first = message('claim-first', 'first') + const second = message('claim-second', 'second') + const canceled = message('claim-canceled', 'canceled') + const requeued = message('claim-requeued', 'requeued') + const later = message('claim-later', 'later') + const current = snapshot(assembler([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [first], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 1, inserted: [canceled], + }), + at(3, 'agent/inbox/spliced', { + target: 'next-step', start: 1, inserted: [second], + }), + at(4, 'agent/inbox/spliced', { + target: 'next-step', start: 2, removedCount: 1, inserted: [], outcome: 'canceled', + }), + at(5, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 2, inserted: [], + }), + at(6, 'user/message', first), + at(7, 'user/message', second), + at(8, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [requeued], + }), + at(9, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(10, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [requeued], + }), + at(11, 'user/message', requeued), + at(12, 'user/message', canceled), + at(13, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', + }), + at(14, 'agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [later], + }), + at(15, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(16, 'user/message', later), + ])) + + expect(current.eventNodes.filter(node => + node.kind === 'user' || node.kind === 'steering').map(node => ({ + kind: node.kind, + seq: node.seq, + }))).toEqual([ + { kind: 'steering', seq: 6 }, + { kind: 'steering', seq: 7 }, + { kind: 'user', seq: 11 }, + { kind: 'user', seq: 12 }, + { kind: 'steering', seq: 16 }, + ]) + }) }) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index be8d264f6f..5f61e791ac 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/agent-loop/README.md -README.md: a37d75545b4c1eccb4363c7ff2f09ae102f480db -README.zh.md: a209e6a99d96cb8790a19b0ebb043332d619026f +README.md: 60d1e75a5038500a42244a45fb077e1eb71da092 +README.zh.md: 166ba6ff366f9a8c8c687b4521bb04b591814851 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a37d75545b..60d1e75a50 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -107,7 +107,7 @@ Creation is one rollback-covered transaction: construct a private session, concr ### Turn and step flow -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step; each successful model call appends one `assistant/message` anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each successful model call appends one `assistant/message` anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. ### Failure and cancellation diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index a209e6a99d..166ba6ff36 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -107,7 +107,7 @@ const handle = await ctx.agents.create({ ### 轮次与步骤流程 -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤;每次成功的模型调用都恰好追加一个引用其分片 seq 的 `assistant/message` 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次成功的模型调用都恰好追加一个引用其分片 seq 的 `assistant/message` 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 ### 失败与取消 diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index e01fd73b01..e57c0e2109 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -512,6 +512,55 @@ describe('adapter registration, routing, and accepted-input ownership', () => { expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) + it('records each admitted next-step batch before the following claim', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'steer_next', {}), + toolCallResponse('c2', 'steer_next', {}), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const steering = [ + createUserMessage({ content: [{ type: 'text', text: 'first steer' }], source: { kind: 'user' } }), + createUserMessage({ content: [{ type: 'text', text: 'second steer' }], source: { kind: 'user' } }), + ] + const agent = ctx.agentLoop.create(SessionId('claim-order'), { provider: 'mock', model: 'mock' }) + let execution = 0 + ctx.tools.register(defineContentToolFixture({ + name: 'steer_next', + description: '', + parameters: {}, + async execute() { + const message = steering[execution] + execution += 1 + if (message !== undefined) agent.steer(message) + return [] + }, + })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const events = agent.session.events + const claims = events.flatMap(event => event.type === 'agent/inbox/spliced' + && event.data.target === 'next-step' + && event.data.outcome !== 'canceled' + && (event.data.removedCount ?? 0) > 0 + ? [event] + : []) + expect(claims).toHaveLength(2) + for (const [index, message] of steering.entries()) { + const claim = claims[index] + const admitted = events.find(event => + event.type === 'user/message' && event.data.id === message.id) + expect(claim).toBeDefined() + expect(admitted).toBeDefined() + if (claim === undefined || admitted === undefined) continue + expect(admitted.seq).toBeGreaterThan(claim.seq) + const nextClaim = claims[index + 1] + if (nextClaim !== undefined) expect(admitted.seq).toBeLessThan(nextClaim.seq) + } + }) + }) describe('turn numbering continues across seeded sessions', () => { From 354bf44923408dc612462b06a21a1503d1b118f5 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 30 Aug 2026 20:29:32 +0800 Subject: [PATCH 13/43] fix: ci --- packages/client/ui-tool/tests/tool-row.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 7065f9e5b1..469c6cb8ed 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -264,7 +264,7 @@ describe('ToolRow', () => { ([value, replacer, space]) => typeof value === 'object' && value !== null && 'a' in value - && value.a === 1 + && (value as { a?: unknown }).a === 1 && replacer === null && space === 2, ).length From 4203317e184b519c417ba184303bbbca3d417b0e Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 00:29:45 +0800 Subject: [PATCH 14/43] perf(client): materialize conversation targets on demand --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 16 +- ...09-client-conversation-node-assembly.zh.md | 16 +- ...ry-conversation-context-assembly.i18n.yaml | 4 +- ...rajectory-conversation-context-assembly.md | 14 +- ...ectory-conversation-context-assembly.zh.md | 14 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 7 + docs/subsystems/conversation.zh.md | 7 + ...nversation-node-definitions.client.spec.ts | 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/conversation.ts | 2 +- .../src/client/conversation/assembler.ts | 169 +++++++++++++----- .../src/client/conversation/assembly.ts | 9 +- .../conversation-assembler.client.spec.ts | 95 +++++++++- .../conversation-registry.client.spec.ts | 33 ++++ .../tests/history-transport.perf.client.ts | 3 +- .../tests/produced-files.client.spec.tsx | 2 +- .../tests/goal-command-input.client.spec.tsx | 2 +- .../conversation-definitions.client.spec.ts | 2 +- .../tests/workflow-run.client.spec.tsx | 2 +- 23 files changed, 327 insertions(+), 88 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index d0085672d5..06b52be49b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 4866f228052633af32a882dd4c96dd28e06851b5 -2026-08-09-client-conversation-node-assembly.zh.md: b50762b70d14c764f8ccf5d803993f5801ae755f +2026-08-09-client-conversation-node-assembly.md: bcd1233b325b79b10c3ddbdfc364a1bfbebd3606 +2026-08-09-client-conversation-node-assembly.zh.md: 2050ae7daf37aafa199a95eb90629a4e6f4021ae diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 4866f22805..bcd1233b32 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -307,9 +307,11 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface ## View Builder and React identity -[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) creates an independent per-Session builder for each target. The Registry stores factories and shares no Session's ordering or caches. +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) stores an independent builder factory for each target and shares no Session's ordering or caches. -The Assembler calls `replace({ nodes, timeline })` on low-frequency complete replacements and `apply({ upserts, timeline })` for ordinary prepend/append flushes. Builders receive only final target Nodes already constructed by Definitions. +A target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts. + +Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it. [`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields. @@ -346,15 +348,15 @@ SessionEventLike window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode() for its declared target - -> target View Builder + -> Definition.buildViewNode() for each active target + -> active target View Builder -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## Verification -Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders. +Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, first-subscription activation, monotonic active targets, and per-target Builders. Conversation tests cover every built-in Chat Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. Trajectory tests cover its independently registered Message, Assistant, Tool, Compaction, Request-header, and boundary Definitions together with the preserved stage-oriented view model. @@ -390,6 +392,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran **Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts. +**Deactivate a target when its last subscriber leaves.** Rejected: returning to the View would repeatedly rebuild its complete snapshot. Subscription establishes first use; the target then stays incremental for the remaining Session lifetime. + **Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts. **Register the Turn-data Hook only on the Assistant renderer.** Rejected: current-Node Location access is a common capability of the `conversation.chat.node` slot, not one business renderer. The parent Chat entry registers common inject once, and every keyed renderer shares the same strongly typed contract. @@ -408,6 +412,8 @@ Append does not scan historical Contexts; prepend replays only Contexts whose Ma Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State. +An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates. + Steps and Turns are stable homes for cross-business aggregates. Turn Tail and Deliverables derive their values without renderer scans of global Nodes; slot-level `useTurnData()` narrows common reads to the current Node's Turn and uses selector equality to isolate unrelated updates. Inbox Context retention grows with splice count and claimed message count rather than their cumulative prefixes. This removes duplicate state growth but does not deduplicate message content in durable Session events or bound the loaded event window. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index b50762b70d..2050ae7daf 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -307,9 +307,11 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 ## View Builder 与 React identity -[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 创建独立的 per-Session builder。Registry 保存 factory,不共享某个 Session 的排序或缓存。 +[`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 保存独立的 builder factory,不共享某个 Session 的排序或缓存。 -Assembler 低频完整替换时调用 `replace({ nodes, timeline })`;普通 prepend/append flush 调用 `apply({ upserts, timeline })`。Builder 只接收 Definition 已构造完成的 target Nodes。 +target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。 + +普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。 [`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。 @@ -346,15 +348,15 @@ SessionEventLike window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode() for its declared target - -> target View Builder + -> Definition.buildViewNode() for each active target + -> active target View Builder -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## 验证 -Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。 +Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回、首次订阅 activation、单调 active target 和 per-target Builder。 Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。Trajectory tests 则覆盖它独立注册的 Message、Assistant、Tool、Compaction、Request-header 与 boundary Definition,以及继续保留的 stage-oriented view model。 @@ -390,6 +392,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。 +**最后一个 subscriber 离开时停用 target。** 拒绝:返回该 View 会反复重建完整 snapshot。订阅只确认首次使用;随后 target 在 Session 剩余生命周期中保持增量更新。 + **在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 **只在 Assistant renderer 注册 Turn data Hook。** 拒绝:访问当前 Node Location 是 `conversation.chat.node` slot 的公共能力,不属于某个业务 renderer。父 Chat entry 注册一次 common inject,所有 keyed renderer 共享同一强类型约定。 @@ -408,6 +412,8 @@ Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。 +inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。 + Step/Turn 是业务间共享聚合的稳定宿主。Turn Tail 和 Deliverables 无需由 renderer 扫描全局 Nodes 即可派生值;Slot-level `useTurnData()` 把常见读取限制到当前 Node 所属 Turn,并通过 selector equality 隔离无关更新。 Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随其累计前缀增长。该结构消除了重复 state 增长,但不会对持久 Session event 中的消息正文去重,也不会限制已加载 event window。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml index 31a204685d..61a386bc8f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.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-11-trajectory-conversation-context-assembly.md -2026-08-11-trajectory-conversation-context-assembly.md: ad4bff77b87649a6e8b208aa6a55575c1a4e23f0 -2026-08-11-trajectory-conversation-context-assembly.zh.md: e7bd90dce00a8bd8a23eed8d076c5350df5d7c15 +2026-08-11-trajectory-conversation-context-assembly.md: c041d7a8ba9f39f74cf55f8a35513724eaeb4f54 +2026-08-11-trajectory-conversation-context-assembly.zh.md: 06e7b801b67b789ddffe991916aceee86cc903f8 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md index ad4bff77b8..c041d7a8ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md @@ -18,6 +18,8 @@ Trajectory registers target-owned Conversation Definitions and a `trajectory` Vi Each Definition belongs to one target. Chat and Trajectory may recognize the same durable Event family, but they keep separate State and final Node payloads. They share only the Assembler's exact-ID matching, ordered Matches, Location facts, Reader dependencies, publication scheduling, and replace/prepend/append lifecycle. +Trajectory's target source activates its View Builder on first subscription. Before that subscription, its Definitions maintain current State while the Assembler skips `buildViewNode()` and snapshot assembly. The target remains active after unsubscription, so later visits reuse the incrementally maintained snapshot. + The existing [Trajectory inspection ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the view model. The Trajectory Builder converts materialized target Nodes into its established `eventNodes`, Requests, Tool schemas, running calls, and Location map; layout, table virtualization, selection, Overview, and inspector behavior do not become generic Conversation contracts. ### Business Definitions @@ -52,11 +54,11 @@ Let `E` be the loaded raw Event count, `P` one newly prepended page, `D` the num | Path | Context work | Target snapshot work | Result | |---|---|---|---| -| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Build and order `C` contributions | A full replace remains proportional to the loaded window | -| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events | -| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history | +| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Inactive: none; active: build and order `C` contributions | A full Context replace remains proportional to the loaded window | +| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Inactive: none; active: rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events | +| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Inactive: none; active: replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history | -The Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes. +First activation builds the current `C` target Contexts without rematching Events and calls `replace()` once. Once active, the Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes. Final Event and Request ordering keeps a publication's current upper bound at `O(C log C)`. The migration removes repeated reverse lookups and the old raw-history refold, but it does not claim end-to-end `O(1)` publication. Chat retains its existing keyed snapshot behavior and complexity; adding the Trajectory target does not make Chat scan Trajectory Contexts or Nodes. @@ -90,7 +92,7 @@ Display memoization and search indexing stay separate. Search must include off-s ## Verification -Runtime tests pin target registration, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots. +Runtime tests pin target registration, first-subscription activation, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots. Trajectory Definition and Builder tests pin Assistant streaming and interruption, nested Tool calls and parallel interruption, Compaction and prompt inheritance, Steering classification and Step placement, Request marker order, stable contribution replacement, and prepend expansion. Table, layout, Timeline, and search tests pin deferred Markdown work, throttled index updates, tooltip-time formatting, and stable search results across append and prepend. @@ -98,7 +100,7 @@ Trajectory Definition and Builder tests pin Assistant streaming and interruption Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session. -The retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. The search index still performs a light linear signature pass when its input layout changes. These costs are explicit target-view work, not hidden full Event refolding. +After first activation, the retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. Before activation, the target retains Context State and one target index but no Builder, materialized Node, or snapshot. The search index still performs a light linear signature pass when its input layout changes. Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity. diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md index e7bd90dce0..06e7b801b6 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md @@ -18,6 +18,8 @@ Trajectory 针对共享的 [`ConversationNodeAssembler`](2026-08-09-client-conve 每个 Definition 只属于一个 target。Chat 与 Trajectory 可以识别同一持久 Event 族,但分别维护自己的 State 和最终 Node payload。它们只共享 Assembler 的精确 ID 匹配、有序 Match、Location 事实、Reader 依赖、发布调度,以及 replace/prepend/append 生命周期。 +Trajectory 的 target source 在首次订阅时激活 View Builder。在此次订阅之前,其 Definition 会维护最新 State,而 Assembler 跳过 `buildViewNode()` 和 snapshot assembly。取消订阅后 target 仍保持 active,因此后续访问会复用持续增量维护的 snapshot。 + 既有的 [Trajectory 检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.zh.md)继续作为视图模型。Trajectory Builder 把已物化的 target Node 转换为原有的 `eventNodes`、Requests、Tool schema、运行中调用和 Location map;layout、表格虚拟化、选择、Overview 与检查器行为不会成为通用 Conversation 约定。 ### 业务 Definition @@ -52,11 +54,11 @@ Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决 | 链路 | Context 工作量 | Target snapshot 工作量 | 结果 | |---|---|---|---| -| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | 构造并排序 `C` 个 contribution | 完整 replace 仍与已加载窗口成正比 | -| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | 从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event | -| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | snapshot 组装前,以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 | +| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | inactive:无;active:构造并排序 `C` 个 contribution | 完整 Context replace 仍与已加载窗口成正比 | +| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | inactive:无;active:从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event | +| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | inactive:无;active:在 snapshot 组装前以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 | -Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。 +首次激活会从当前 `C` 个 target Context 构建 Node,而不会重新匹配 Event,并调用一次 `replace()`。激活后,Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。 最终 Event 和 Request 排序使单次发布的当前上界保持为 `O(C log C)`。本次迁移移除了重复反向查找和旧的原始历史 refold,但不声称端到端发布达到 `O(1)`。Chat 保持既有 keyed snapshot 行为与复杂度;增加 Trajectory target 不会让 Chat 扫描 Trajectory Context 或 Node。 @@ -90,7 +92,7 @@ Context 迁移与下列表现层优化解决的是不同成本。这些优化保 ## 验证 -Runtime 测试固定 target 注册、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。 +Runtime 测试固定 target 注册、首次订阅 activation、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。 Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interruption、嵌套 Tool call 和并行 interruption、Compaction 与 prompt 继承、Steering 分类和 Step 位置、Request 标记顺序、稳定 contribution 替换与 prepend 扩展。Table、layout、Timeline 与搜索测试固定延迟 Markdown 工作、节流索引更新、Tooltip 展示时格式化,以及 append/prepend 期间稳定的搜索结果。 @@ -98,7 +100,7 @@ Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interrupt Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。 -保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。这些成本是显式的 target view 工作,不是隐藏的完整 Event refold。 +首次激活后,保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。激活前,target 保留 Context State 和一个 target 索引,但不保留 Builder、已物化 Node 或 snapshot。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。 Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index a915f868f5..ae1f00aecf 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.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/conversation.md -conversation.md: 28a7b3d497182f2f560f2b54f89fcfd3091af673 -conversation.zh.md: 7cf98fe2b18d9fd53e5f49f48330a5585e04c54d +conversation.md: 1a82b9981fd69f1cef23b90ba0b5d2bc751610d3 +conversation.zh.md: 80a8d371eefb518e8caedca230c90b5040a32a13 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 28a7b3d497..1a82b9981f 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -20,6 +20,12 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence. +## Target activation + +Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it; its first subscriber does. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one. + +The mounted View controls demand through its target source. Persisted selection, a tab switch, and a third-party View use the same subscription path, so the assembler does not select Chat or another default target. + ## Replayable event families Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. @@ -247,5 +253,6 @@ Add focused tests that establish these outcomes: 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. 7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild. +8. Creating a target source performs no builder work; its first subscription performs one complete replacement, later updates reach every active target, and resubscription performs no replacement. Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 7cf98fe2b1..80a8d371ee 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -20,6 +20,12 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。 +## Target 激活 + +每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它,首个 subscriber 才会激活。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。 + +已挂载的 View 通过自己的 target source 控制需求。持久化选择、标签页切换和第三方 View 使用同一订阅路径,因此 assembler 不会指定 Chat 或其他默认 target。 + ## 可回放 event family 编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 @@ -247,5 +253,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。 +8. 创建 target source 不执行 builder 工作;首次订阅执行一次完整 replace,后续更新送达所有 active target,重新订阅不会再次 replace。 流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 2be5b239df..f9ad400efe 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -106,7 +106,7 @@ function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLi function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler { const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) value.replaceWindow(entries, hasMore) - value.flush() + value.activateTarget('chat') return value } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 26499140f2..4d1830c250 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: 629b8b4f7987fc072066e58692396354f7b6ad6a -README.zh.md: cb622f0308ddb0a978cfb665105d79aadfb3135a +README.md: b88c8015a26ae7b83a60fd058804c0e838643a6f +README.zh.md: 4f65b861965e241f7553942407c5a23536703b33 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 629b8b4f79..b88c8015a2 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -29,6 +29,8 @@ English | [中文](README.zh.md) The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. +A target source creates its Session-owned builder on first subscription. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it, unsubscription does not deactivate it, and no built-in target is forced active. + Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index cb622f0308..4f65b86196 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 +target source 在首次订阅时创建该 Session 自有的 builder。assembler 从当前 Context 对该 target 执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅不会停用 target,也不会强制激活任何内建 target。 + target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 1fdb89053f..7864383947 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -253,7 +253,7 @@ export interface ConversationViewBuilder { readonly target: string /** @returns a new Session-owned incremental builder. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index a8267bd6ba..e3acebded6 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -44,8 +44,9 @@ interface PendingMatch { interface ViewState { readonly target: string - readonly builder: ConversationViewBuilder + readonly definition: ConversationViewDefinition readonly isActive: ((snapshot: unknown) => boolean) | undefined + builder: ConversationViewBuilder | undefined snapshot: unknown } @@ -158,12 +159,15 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private readonly contexts = new Map() private readonly contextsByKind = new Map() private readonly contextsBySeq = new Map>() + private readonly contextsByTarget = new Map>() private readonly inputs = new Map() private readonly locationIndex = new ConversationLocationIndex() private readonly dirty = new Set() + private readonly dirtyByTarget = new Map>() private readonly revised = new Set() private readonly dependents = new Map>() private readonly views = new Map() + private readonly activeTargets = new Set() private hasMore = false private replacePending = true private timelineDirty = true @@ -189,8 +193,10 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.contexts.clear() this.contextsByKind.clear() this.contextsBySeq.clear() + this.contextsByTarget.clear() this.inputs.clear() this.dirty.clear() + this.dirtyByTarget.clear() this.revised.clear() this.dependents.clear() this.hasMore = hasMore @@ -201,7 +207,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore for (const entry of sorted) this.matchInput(entry) this.replayDependencies() this.revised.clear() - for (const context of this.contexts.values()) this.dirty.add(context) + for (const context of this.contexts.values()) this.markDirty(context) this.replacePending = true return 'immediate' } @@ -278,68 +284,72 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } /** - * Materialize dirty Contexts and advance every registered view builder. + * Materialize dirty Contexts and advance every active view builder. * @returns whether any view snapshot was rebuilt or incrementally applied. */ flush(): boolean { if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false if (this.replacePending) { this.replaceLocationData() - const allByTarget = new Map() - for (const target of this.views.keys()) allByTarget.set(target, []) - for (const context of this.contexts.values()) { - const target = context.definition.target - if (target === undefined || !this.views.has(target)) continue - const node = this.buildNode(context, target) - context.current.set(target, node) - if (node !== null) allByTarget.get(target)?.push(node) - } - for (const view of this.views.values()) { - view.snapshot = view.builder.replace({ - nodes: allByTarget.get(view.target) ?? [], + for (const target of this.activeTargets) { + const view = this.views.get(target) + if (view === undefined) continue + const builder = view.builder ?? view.definition.create() + view.builder = builder + view.snapshot = builder.replace({ + nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)), timeline: this.locationIndex.snapshot(), }) } this.replacePending = false this.dirty.clear() + this.dirtyByTarget.clear() this.timelineDirty = false - return true + return this.activeTargets.size > 0 } - const upsertsByTarget = new Map() - for (const target of this.views.keys()) upsertsByTarget.set(target, []) + let published = false if (this.applyDirtyLocationData()) this.timelineDirty = true - for (const context of this.dirty) { - const target = context.definition.target - if (target === undefined || !this.views.has(target)) continue - const previous = context.current.get(target) ?? null - const node = this.buildNode(context, target) - if (node === null && previous !== null) { - throw new Error( - `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, - ) - } - context.current.set(target, node) - if (node !== null) upsertsByTarget.get(target)?.push(node) - } - this.dirty.clear() const timelineDirty = this.timelineDirty - this.timelineDirty = false - for (const view of this.views.values()) { - const upserts = upsertsByTarget.get(view.target) ?? [] + for (const target of this.activeTargets) { + const view = this.views.get(target) + if (view === undefined) continue + const builder = view.builder + if (builder === undefined) continue + const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target)) if (upserts.length === 0 && !timelineDirty) continue - view.snapshot = view.builder.apply({ + view.snapshot = builder.apply({ upserts, timeline: this.locationIndex.snapshot(), }) + published = true } + this.dirty.clear() + this.dirtyByTarget.clear() + this.timelineDirty = false + return published + } + + /** + * Add one target to the monotonic active set and materialize its current snapshot. + * Pending Context work is flushed before the first complete replacement. + * @param target - registered or subsequently registered view target. + * @returns whether any active target snapshot changed. + */ + activateTarget(target: string): boolean { + const view = this.views.get(target) + if (this.activeTargets.has(target)) return false + const published = this.flush() + this.activeTargets.add(target) + if (view === undefined) return published + this.replaceView(view) return true } /** * Read the latest snapshot of a registered target. * @param target - registered view target. - * @returns target snapshot, or undefined when no builder is registered. + * @returns target snapshot, or undefined before registration or activation. */ snapshot(target: string): unknown { return this.views.get(target)?.snapshot @@ -353,11 +363,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * Read targets whose owners classify their latest snapshot as visible activity. - * @returns active target ids. + * @returns target ids contributing visible activity. */ - activeTargets(): ReadonlySet { + activityTargets(): ReadonlySet { const active = new Set() - for (const view of this.views.values()) { + for (const target of this.activeTargets) { + const view = this.views.get(target) + if (view === undefined) continue if (view.isActive?.(view.snapshot) === true) active.add(view.target) } return active @@ -446,6 +458,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore dependencies: new Map(), } this.contexts.set(key, context) + this.indexTargetContext(context) } const match = conversationMatch( key, @@ -478,7 +491,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore context.revision++ this.revised.add(context) } - this.dirty.add(context) + this.markDirty(context) return definition.publication?.(match) ?? 'immediate' } @@ -507,6 +520,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore dependencies: new Map(), } this.contexts.set(key, context) + this.indexTargetContext(context) } let discoveredStart: ConversationStartMatch | undefined const additions = entries @@ -538,7 +552,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore throw new Error(`conversation Context ${context.key} received an update before its start Match`) } affected.add(context) - this.dirty.add(context) + this.markDirty(context) } for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) } @@ -549,7 +563,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore for (const context of ordered) { if (context.start === undefined) { context.state = undefined - this.dirty.add(context) + this.markDirty(context) continue } this.replayContext(context) @@ -586,7 +600,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } context.revision++ this.revised.add(context) + this.markDirty(context) + } + + private indexTargetContext(context: InternalContext): void { + const target = context.definition.target + if (target === undefined) return + const contexts = this.contextsByTarget.get(target) ?? new Set() + contexts.add(context) + this.contextsByTarget.set(target, contexts) + } + + private markDirty(context: InternalContext): void { this.dirty.add(context) + const target = context.definition.target + if (target === undefined || !this.activeTargets.has(target)) return + const contexts = this.dirtyByTarget.get(target) ?? new Set() + contexts.add(context) + this.dirtyByTarget.set(target, contexts) } private replaceDependencies(context: InternalContext, dependencies: Map): void { @@ -759,6 +790,47 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return node } + private replaceView(view: ViewState): void { + const builder = view.builder ?? view.definition.create() + view.builder = builder + view.snapshot = builder.replace({ + nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), + timeline: this.locationIndex.snapshot(), + }) + } + + private buildTargetNodes( + target: string, + contexts: Iterable | undefined, + ): ConversationViewNode[] { + const nodes: ConversationViewNode[] = [] + for (const context of contexts ?? []) { + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) nodes.push(node) + } + return nodes + } + + private buildTargetUpserts( + target: string, + contexts: Iterable | undefined, + ): ConversationViewNode[] { + const upserts: ConversationViewNode[] = [] + for (const context of contexts ?? []) { + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) + } + context.current.set(target, node) + if (node !== null) upserts.push(node) + } + return upserts + } + private buildLocationData( context: InternalContext, scope: ConversationLocationDataScope, @@ -817,15 +889,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private resetViewBuilders(): void { this.views.clear() for (const definition of this.viewDefinitions.entries()) { - const builder = definition.create() - this.views.set(definition.target, { + const view: ViewState = { target: definition.target, - builder, + definition, isActive: definition.isActive === undefined ? undefined : snapshot => definition.isActive?.(snapshot) === true, - snapshot: builder.empty, - }) + builder: undefined, + snapshot: undefined, + } + this.views.set(definition.target, view) } this.replacePending = true } diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 6cec0a0fa1..cf0f0c0da7 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -25,6 +25,7 @@ export interface ConversationBinding { readonly snapshot: ObservableSnapshot /** * Resolve one target-owned snapshot source. + * The first subscriber activates the target for the remaining Session lifetime. * @param target - registered Conversation target. * @returns identity-stable source following the target. */ @@ -61,7 +62,11 @@ class BoundConversation implements ConversationBinding { const views = this.viewStore as unknown as { get(key: string): unknown } source = { getSnapshot: () => views.get(target), - subscribe: (listener) => { return this.snapshot.subscribe(listener) }, + subscribe: (listener) => { + const unsubscribe = this.snapshot.subscribe(listener) + if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + return unsubscribe + }, } this.targetSources.set(target, source) } @@ -125,7 +130,7 @@ class BoundConversation implements ConversationBinding { private currentSnapshot(): ConversationSnapshot { return { views: this.viewStore, - activeTargets: this.assembler.activeTargets(), + activeTargets: this.assembler.activityTargets(), } } } diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 6465559e96..db5b0ebdb5 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -5,7 +5,7 @@ import type { import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ConversationNodeAssembler as RuntimeConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, @@ -63,6 +63,13 @@ class TestViewDefinitions { } } +class ConversationNodeAssembler extends RuntimeConversationNodeAssembler { + constructor(events: TestEventDefinitions, views: TestViewDefinitions) { + super(events, views) + for (const view of views.entries()) this.activateTarget(view.target) + } +} + function testView( apply = vi.fn(), ): ConversationViewDefinition { @@ -92,6 +99,21 @@ function testView( } } +function trackedView(target: string) { + const replace = vi.fn(({ nodes }: { readonly nodes: readonly ConversationViewNode[] }) => nodes) + const apply = vi.fn(({ upserts }: { readonly upserts: readonly ConversationViewNode[] }) => upserts) + const create = vi.fn(() => ({ + empty: [] as readonly ConversationViewNode[], + replace, + apply, + })) + const definition: ConversationViewDefinition = { + target, + create, + } + return { definition, create, replace, apply } +} + function at(seq: number, type: string, data: unknown): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent } @@ -139,6 +161,77 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + it('updates only active targets and never deactivates one after first use', () => { + type State = { readonly updates: number } + const definition = ( + target: string, + buildViewNode: NonNullable['buildViewNode']>, + ): ConversationNodeDefinition => ({ + kind: `active-${target}`, + target, + match: (event) => { + const type = event.type as string + if (type === 'active/start') return { id: 'one', role: 'start' } + if (type === 'active/update') return { id: 'one', role: 'update' } + return null + }, + start: () => ({ updates: 0 }), + update: context => ({ updates: context.state.updates + 1 }), + buildViewNode, + }) + const chat = trackedView('chat') + const trajectory = trackedView('trajectory') + const build = (target: string) => vi.fn((context: ConversationNodeContext): ConversationViewNode => ({ + key: context.key, + kind: context.kind, + id: context.id, + target, + data: context.state, + })) + const buildChat = build('chat') + const buildTrajectory = build('trajectory') + const assembler = new RuntimeConversationNodeAssembler( + new TestEventDefinitions([ + definition('chat', buildChat), + definition('trajectory', buildTrajectory), + ]), + new TestViewDefinitions([chat.definition, trajectory.definition]), + ) + + assembler.replaceWindow([input(at(1, 'active/start', {}))], false) + expect(assembler.flush()).toBe(false) + expect(chat.create).not.toHaveBeenCalled() + expect(trajectory.create).not.toHaveBeenCalled() + expect(buildChat).not.toHaveBeenCalled() + expect(buildTrajectory).not.toHaveBeenCalled() + + expect(assembler.activateTarget('chat')).toBe(true) + expect(chat.replace).toHaveBeenCalledOnce() + expect(trajectory.replace).not.toHaveBeenCalled() + expect(buildChat).toHaveBeenCalledOnce() + expect(buildTrajectory).not.toHaveBeenCalled() + + assembler.append(input(at(2, 'active/update', {}))) + expect(assembler.flush()).toBe(true) + expect(chat.apply).toHaveBeenCalledOnce() + expect(trajectory.apply).not.toHaveBeenCalled() + expect(buildTrajectory).not.toHaveBeenCalled() + + expect(assembler.activateTarget('trajectory')).toBe(true) + expect(trajectory.replace).toHaveBeenCalledOnce() + expect((assembler.snapshot('trajectory') as readonly ConversationViewNode[]) + .map(node => node.data)).toEqual([{ updates: 1 }]) + + assembler.append(input(at(3, 'active/update', {}))) + expect(assembler.flush()).toBe(true) + expect(chat.apply).toHaveBeenCalledTimes(2) + expect(trajectory.apply).toHaveBeenCalledOnce() + expect(assembler.activateTarget('chat')).toBe(false) + expect(assembler.activateTarget('trajectory')).toBe(false) + expect(chat.replace).toHaveBeenCalledOnce() + expect(trajectory.replace).toHaveBeenCalledOnce() + }) + it('appends through an exact business-id Context without replaying unrelated Contexts', () => { const starts = vi.fn(( _context: ConversationNodeContext<{ callSeq: number; results: number }>, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 9ef8c333dd..e1dcfceb23 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -245,4 +245,37 @@ describe('Conversation registries', () => { expect(rebuild).toHaveBeenCalledTimes(2) rebuild.mockRestore() }) + + it('activates each target on first use and keeps it active after unsubscribe', async () => { + const { uiConversation, binding, views } = await bootRegistries() + const chat = viewDefinition('chat') + const trajectory = viewDefinition('trajectory') + const createChat = vi.spyOn(chat, 'create') + const createTrajectory = vi.spyOn(trajectory, 'create') + views.register(chat) + const disposeTrajectory = views.register(trajectory) + await Promise.resolve() + + const conversation = uiConversation.binding(binding) + const chatSource = conversation.target('chat') + const trajectorySource = conversation.target('trajectory') + expect(createChat).not.toHaveBeenCalled() + expect(createTrajectory).not.toHaveBeenCalled() + + const unsubscribeChat = chatSource.subscribe(vi.fn()) + const trajectoryListener = vi.fn() + const unsubscribeTrajectory = trajectorySource.subscribe(trajectoryListener) + unsubscribeChat() + const unsubscribeTrajectoryAgain = trajectorySource.subscribe(vi.fn()) + unsubscribeTrajectoryAgain() + expect(createChat).toHaveBeenCalledOnce() + expect(createTrajectory).toHaveBeenCalledOnce() + expect(trajectoryListener).toHaveBeenCalledOnce() + + disposeTrajectory() + await Promise.resolve() + expect(trajectorySource.getSnapshot()).toBeUndefined() + expect(trajectoryListener).toHaveBeenCalledTimes(2) + unsubscribeTrajectory() + }) }) diff --git a/packages/client/ui-conversation/tests/history-transport.perf.client.ts b/packages/client/ui-conversation/tests/history-transport.perf.client.ts index 57ddd806e1..65f2b42893 100644 --- a/packages/client/ui-conversation/tests/history-transport.perf.client.ts +++ b/packages/client/ui-conversation/tests/history-transport.perf.client.ts @@ -441,7 +441,8 @@ function assemble(entries: readonly SessionEventLikeEntry[]): FoldSnapshots { { entries: () => [viewDefinition('chat'), viewDefinition('trajectory')] }, ) assembler.replaceWindow(entries, false) - assembler.flush() + assembler.activateTarget('chat') + assembler.activateTarget('trajectory') return { chat: assembler.snapshot('chat'), trajectory: assembler.snapshot('trajectory'), diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 56821c7ecd..3922c22a68 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -165,7 +165,7 @@ function result(seq: number, callId: string, isError = false, turn = 1): Session function assembler(entries: readonly SessionLiveEventEntry[], hasMore = false): ConversationNodeAssembler { const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) value.replaceWindow(entries, hasMore) - value.flush() + value.activateTarget('test') return value } diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index 6c8c1e34cc..d149cd7f4f 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -48,7 +48,7 @@ function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry function snapshot(entries: readonly SessionLiveEventEntry[], hasMore = false): ChatSnapshot { const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) assembler.replaceWindow(entries, hasMore) - assembler.flush() + assembler.activateTarget('chat') const value = assembler.snapshot('chat') as ChatSnapshot | undefined if (value === undefined) throw new Error('chat view was not registered') return value diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 9b1a243a52..81fa4d92df 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -97,7 +97,7 @@ function assembler(events: readonly SessionEventLikeEntry[]): ConversationNodeAs new TestViewDefinitions(), ) value.replaceWindow(events, false) - value.flush() + value.activateTarget('trajectory') return value } diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 1758c8c27e..1df7e73b27 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -100,7 +100,7 @@ function matched(input: SessionLiveEventEntry, role: ConversationMatch['role']): function assembler(entries: readonly SessionLiveEventEntry[], hasMore = false): ConversationNodeAssembler { const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) value.replaceWindow(entries, hasMore) - value.flush() + value.activateTarget('chat') return value } From 4b21065dbe7fa26834053bb73acc1ba9fa5772b3 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 00:59:19 +0800 Subject: [PATCH 15/43] refactor(client): share conversation context initialization --- .../src/client/conversation/assembler.ts | 62 ++++++++----------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index e3acebded6..ff74cab006 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -431,6 +431,30 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return publication } + private createContext( + definition: ConversationNodeDefinition, + id: string, + key: string, + ): InternalContext { + const context: InternalContext = { + key, + kind: definition.kind, + id, + definition, + startSeq: undefined, + start: undefined, + matches: [], + state: undefined, + revision: 0, + current: new Map(), + locationData: emptyLocationData(), + dependencies: new Map(), + } + this.contexts.set(key, context) + this.indexTargetContext(context) + return context + } + private acceptMatch( definition: ConversationNodeDefinition, id: string, @@ -442,24 +466,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (role === 'start' && context?.start !== undefined) { throw new Error(`conversation Context ${key} received more than one start Match`) } - if (context === undefined) { - context = { - key, - kind: definition.kind, - id, - definition, - startSeq: undefined, - start: undefined, - matches: [], - state: undefined, - revision: 0, - current: new Map(), - locationData: emptyLocationData(), - dependencies: new Map(), - } - this.contexts.set(key, context) - this.indexTargetContext(context) - } + context ??= this.createContext(definition, id, key) const match = conversationMatch( key, input, @@ -504,24 +511,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore const first = entries[0] if (first === undefined) continue let context = this.contexts.get(key) - if (context === undefined) { - context = { - key, - kind: first.definition.kind, - id: first.id, - definition: first.definition, - startSeq: undefined, - start: undefined, - matches: [], - state: undefined, - revision: 0, - current: new Map(), - locationData: emptyLocationData(), - dependencies: new Map(), - } - this.contexts.set(key, context) - this.indexTargetContext(context) - } + context ??= this.createContext(first.definition, first.id, key) let discoveredStart: ConversationStartMatch | undefined const additions = entries .map((entry) => { From d7abd0a01ead93958697a95ed7e9bca15844e9ec Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 12:19:16 +0800 Subject: [PATCH 16/43] fix(web): activate selected view for blank sessions fix(web): activate selected views before blank gating --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 4 +- ...09-client-conversation-node-assembly.zh.md | 4 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 6 +- docs/subsystems/conversation.zh.md | 6 +- .../src/client/conversation-nodes/inbox.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../ui-conversation/src/client/apply.ts | 49 +++++++++-- .../src/client/contract/slots.ts | 4 + .../src/client/conversation/assembler.ts | 4 +- .../src/client/conversation/assembly.ts | 14 ++- .../client/skeleton/ConversationSession.tsx | 20 ++--- .../ui-conversation/src/client/stores.ts | 23 ++++- .../src/client/view-selection.ts | 17 ++++ .../tests/apply-inject.client.spec.tsx | 85 ++++++++++++++++++- .../conversation-assembler.client.spec.ts | 12 +++ .../conversation-registry.client.spec.ts | 5 +- .../tests/conversation-store.client.spec.ts | 13 ++- .../tests/skeleton.client.spec.tsx | 5 +- .../tests/view-selection.client.spec.ts | 20 +++++ .../ui-trajectory/tests/views.client.spec.tsx | 3 + 24 files changed, 265 insertions(+), 51 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/view-selection.ts create mode 100644 packages/client/ui-conversation/tests/view-selection.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 06b52be49b..c60b8ae3ba 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: bcd1233b325b79b10c3ddbdfc364a1bfbebd3606 -2026-08-09-client-conversation-node-assembly.zh.md: 2050ae7daf37aafa199a95eb90629a4e6f4021ae +2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520 +2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index bcd1233b32..4831c22617 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -309,7 +309,9 @@ Unknown fallback demonstrates Registry ownership: it handles only append-surface [`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) stores an independent builder factory for each target and shares no Session's ordering or caches. -A target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts. +A shell selection or a target source's first subscriber adds that target to the Session's monotonic active-target set. The Assembler indexes each Context under its sole target but creates no builder, Node, or snapshot for an inactive target. First activation flushes pending target-neutral work, creates the builder, and calls `replace({ nodes, timeline })` once from that target's current Contexts. + +The shell synchronously resolves the persisted selection when a Session binding becomes available, when a cached binding becomes current, or when the View roster changes, then explicitly activates that registered View or the Chat fallback. Tab and focus actions activate their resolved target before updating selection state. A blank Session does not render the View slot, and `ConversationSnapshot.activeTargets` derives only from materialized active snapshots without querying inactive target Contexts for activity. Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 2050ae7daf..37463d0543 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -309,7 +309,9 @@ Unknown fallback 展示了 Registry ownership:fallback 只处理没有任何 [`ConversationViewRegistry`](../../../../packages/client/ui-conversation/src/client/conversation/view-registry.ts) 为每个 target 保存独立的 builder factory,不共享某个 Session 的排序或缓存。 -target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。 +shell 选择或 target source 的首个 subscriber 会把该 target 加入 Session 单调增长的 active-target set。Assembler 按唯一 target 索引每个 Context,但不会为 inactive target 创建 builder、Node 或 snapshot。首次激活会 flush 尚未发布的 target-neutral 工作、创建 builder,并从该 target 的当前 Context 调用一次 `replace({ nodes, timeline })`。 + +Session binding 可用、缓存的 binding 成为 current 或 View roster 变化时,shell 会同步解析持久化选择,再显式激活已注册的偏好 View 或 Chat fallback。Tab 与 focus action 在更新选择状态前先激活解析出的 target。blank Session 不渲染 View slot;`ConversationSnapshot.activeTargets` 只从已物化的 active snapshot 派生,不查询 inactive target Context 的 activity。 普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index ae1f00aecf..68358c4f1e 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.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/conversation.md -conversation.md: 1a82b9981fd69f1cef23b90ba0b5d2bc751610d3 -conversation.zh.md: 80a8d371eefb518e8caedca230c90b5040a32a13 +conversation.md: df1476537b95690ae2055f367e8586653b99a9a9 +conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 1a82b9981f..df1476537b 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -22,9 +22,9 @@ Chat and Trajectory may recognize the same durable event family, but each keeps ## Target activation -Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it; its first subscriber does. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one. +Each Session keeps a monotonic set of active targets. Creating or reading a target source does not activate it. The shell explicitly activates its persisted or newly selected View, while another consumer activates a target through its first source subscription. First activation creates that target's builder and calls `replace()` once from the current target-indexed Contexts. Later flushes call `apply()` for every active target, and unsubscription does not remove one. -The mounted View controls demand through its target source. Persisted selection, a tab switch, and a third-party View use the same subscription path, so the assembler does not select Chat or another default target. +The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. ## Replayable event families @@ -253,6 +253,6 @@ Add focused tests that establish these outcomes: 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. 6. The keyed renderer consumes `node.data` and constrained Location hooks only; it does not scan the Session event window, Contexts, or Chat Nodes. 7. Scalar and packed Assistant history produce the same final State, timing boundaries, and target snapshot, while one packed run remains one Match through replace, prepend, Location replay, and registry rebuild. -8. Creating a target source performs no builder work; its first subscription performs one complete replacement, later updates reach every active target, and resubscription performs no replacement. +8. Creating a target source performs no builder work; explicit selection or the first subscription performs one complete replacement, later updates reach every active target, and repeated activation performs no replacement. Use [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts) for streaming and interruption, [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) plus [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) for predecessor queries, and [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables) for a Definition that publishes Turn data without creating its own Node. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 80a8d371ee..784f52975c 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -22,9 +22,9 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留 ## Target 激活 -每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它,首个 subscriber 才会激活。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。 +每个 Session 都保留单调增长的 active target 集合。创建或读取 target source 不会激活它。shell 会显式激活持久化选择或新选择的 View,其他消费者则通过 target source 的首个订阅激活 target。首次激活会创建该 target 的 builder,并从当前按 target 索引的 Context 调用一次 `replace()`。后续 flush 对每个 active target 调用 `apply()`,取消订阅不会移除 target。 -已挂载的 View 通过自己的 target source 控制需求。持久化选择、标签页切换和第三方 View 使用同一订阅路径,因此 assembler 不会指定 Chat 或其他默认 target。 +shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。 ## 可回放 event family @@ -253,6 +253,6 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 6. keyed renderer 只消费 `node.data` 与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。 7. scalar 与 packed Assistant 历史产生相同的最终 State、timing boundary 和 target snapshot;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。 -8. 创建 target source 不执行 builder 工作;首次订阅执行一次完整 replace,后续更新送达所有 active target,重新订阅不会再次 replace。 +8. 创建 target source 不执行 builder 工作;显式选择或首次订阅执行一次完整 replace,后续更新送达所有 active target,重复激活不会再次 replace。 流式与中断处理可参考 [`packages/client/ui-chat/src/client/conversation-nodes/assistant.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/assistant.ts),前序查询可参考 [`inbox.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 与 [`message.ts`](../../packages/client/ui-chat/src/client/conversation-nodes/message.ts),只发布 Turn data 而不创建自有 Node 的例子见 [`packages/client/ui-deliverables`](../../packages/client/ui-deliverables)。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts index d2a94dd72d..eed4615e9f 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts @@ -106,7 +106,7 @@ function applySplice( const NEXT_STEP_INBOX_KIND = 'inbox-next-step' -/** Cumulative next-step Inbox state used to classify steering. */ +/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */ export const nextStepInboxDefinition: ConversationNodeDefinition = { kind: NEXT_STEP_INBOX_KIND, match: (event) => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4d1830c250..429128c5b0 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: b88c8015a26ae7b83a60fd058804c0e838643a6f -README.zh.md: 4f65b861965e241f7553942407c5a23536703b33 +README.md: ce99ede306e0b0aeac24d7b21beacd0a538380b6 +README.zh.md: 5a2e481071ed3303983d5b57c68690ea9f4c4ab3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b88c8015a2..ce99ede306 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -29,7 +29,7 @@ English | [中文](README.zh.md) The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes scalar and packed records, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Historical replace and prepend accept both entry variants, while live append accepts only `SessionLiveEventEntry`. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a standard event and the assembler rejects a packed start. Definitions that do not consume Assistant deltas return `null` for the packed tags. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append and prepend revisions use incremental assembly without expanding packed members. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. -A target source creates its Session-owned builder on first subscription. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it, unsubscription does not deactivate it, and no built-in target is forced active. +A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. @@ -40,6 +40,8 @@ The package registers the optional-Session `conversation` shell, strict Session View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell. +The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. + The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4f65b86196..5a2e481071 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" adapter 把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分 scalar 与 packed record,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。历史 replace 与 prepend 接受两种 entry,实时 append 只接受 `SessionLiveEventEntry`。两种 event 都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 则只接收标准 event,assembler 会拒绝 packed start。不消费 Assistant delta 的 Definition 对 packed tag 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append 和 prepend 使用增量组装,并且不展开 packed member。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 -target source 在首次订阅时创建该 Session 自有的 builder。assembler 从当前 Context 对该 target 执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅不会停用 target,也不会强制激活任何内建 target。 +shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。 @@ -40,6 +40,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。 +Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 + 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index b154e73f4a..87623ca3c3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -16,7 +16,7 @@ import type { ConversationSessionInjected, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' -import { createConversationStore } from './stores.ts' +import { createConversationStore, readConversationViewPreference } from './stores.ts' import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' @@ -30,6 +30,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' +import { resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -129,25 +130,47 @@ export function apply(ctx: Context): void { } return tabs } + const activateView = (sessionId: SessionId, preferred: string | null): void => { + const active = resolveActiveView(viewTabs(), preferred) + if (active !== undefined) uiConversation.binding(sessionId).activate(active.id) + } + const restoreView = (sessionId: SessionId): void => { + activateView(sessionId, readConversationViewPreference(sessionId)) + } + const restoreCurrentView = (): void => { + const sessionId = sessions.list.getSnapshot().current + if (sessionId !== undefined && sessions.binding(sessionId) !== undefined) { + restoreView(sessionId) + } + } const conversationViews = createSnapshotStore(viewTabs()) const refreshViews = (): void => { const current = conversationViews.getSnapshot() const next = viewTabs() - if (current.length === next.length + const unchanged = current.length === next.length && current.every((tab, index) => { const candidate = next.at(index) return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label - })) return - conversationViews.set(next) + }) + if (!unchanged) conversationViews.set(next) + restoreCurrentView() } ctx.effect(() => { + let currentSessionId = sessions.list.getSnapshot().current const disposeViews = slots.subscribe('conversation.view', refreshViews) const disposeLocale = ctx.locale.subscribe(refreshViews) + const disposeCurrent = sessions.list.subscribe(() => { + const nextSessionId = sessions.list.getSnapshot().current + if (nextSessionId === currentSessionId) return + currentSessionId = nextSessionId + restoreCurrentView() + }) return () => { + disposeCurrent() disposeLocale() disposeViews() } - }, 'ui-conversation: View roster') + }, 'ui-conversation: View selection') const inputHub = new InputHub(ctx, t) const composerBlocks = new ComposerBlockRegistry() @@ -159,9 +182,11 @@ export function apply(ctx: Context): void { props: ['inputActions'], resolve: (binding) => { const shell = inputHub.shellFor(binding) + const conversation = uiConversation.binding(binding) + restoreView(binding.sessionId) return { hooks: { - conversation: uiConversation.binding(binding).snapshot, + conversation: conversation.snapshot, input: shell.state, }, props: { inputActions: shell.actions }, @@ -218,9 +243,13 @@ export function apply(ctx: Context): void { 'conversation.view': { kind: 'list', scope: 'session' }, }, store: conversationStore, - inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ + inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionInjected => ({ hooks: { conversationViews }, bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), + openView: (view, focus) => { + activateView(sessionId, view) + actions.openView(view, focus) + }, }), }, ConversationSession) @@ -233,9 +262,13 @@ export function apply(ctx: Context): void { 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, }, store: conversationStore, - inject: (): ConversationSessionHeaderInjected => ({ + inject: (sessionId: SessionId, actions: BoundActions): ConversationSessionHeaderInjected => ({ hooks: { conversationViews }, open: (id) => { sessions.open(id) }, + selectView: (view) => { + activateView(sessionId, view) + actions.setView(view) + }, }), }, ConversationSessionHeader) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 377a75ee00..1f84c8e3f1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -226,6 +226,8 @@ export interface ConversationSessionInjected { readonly hooks: { readonly conversationViews: ObservableSnapshot } /** Bind input draft persistence to the Session-owned store instance. */ bindDraftMirror: (write: (text: string) => void) => () => void + /** Select and activate one View while addressing an opaque focus request to it. */ + openView: (view: string, focus: string) => void } /** Business callbacks injected into the strict Session header. */ @@ -234,6 +236,8 @@ export interface ConversationSessionHeaderInjected { readonly hooks: { readonly conversationViews: ObservableSnapshot } /** Select a Session through the Session Controller. */ open: (sessionId: SessionId) => void + /** Select and activate one registered Conversation View. */ + selectView: (view: string) => void } /** Owner share of the resident composer bar. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index ff74cab006..df81e4b7b3 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -291,6 +291,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false if (this.replacePending) { this.replaceLocationData() + let published = false for (const target of this.activeTargets) { const view = this.views.get(target) if (view === undefined) continue @@ -300,12 +301,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)), timeline: this.locationIndex.snapshot(), }) + published = true } this.replacePending = false this.dirty.clear() this.dirtyByTarget.clear() this.timelineDirty = false - return this.activeTargets.size > 0 + return published } let published = false diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index cf0f0c0da7..6957f5874e 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -23,9 +23,15 @@ import { ConversationViewRegistry } from './view-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { readonly snapshot: ObservableSnapshot + /** + * Add one selected target to the Session's monotonic active set. + * @param target - registered or subsequently registered Conversation target. + */ + activate(target: string): void /** * Resolve one target-owned snapshot source. - * The first subscriber activates the target for the remaining Session lifetime. + * The first subscriber activates the target unless shell selection already + * activated it; activation lasts for the remaining Session lifetime. * @param target - registered Conversation target. * @returns identity-stable source following the target. */ @@ -64,7 +70,7 @@ class BoundConversation implements ConversationBinding { getSnapshot: () => views.get(target), subscribe: (listener) => { const unsubscribe = this.snapshot.subscribe(listener) - if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + this.activate(target) return unsubscribe }, } @@ -73,6 +79,10 @@ class BoundConversation implements ConversationBinding { return source as ObservableSnapshot } + activate(target: string): void { + if (this.assembler.activateTarget(target)) this.snapshot.set(this.currentSnapshot()) + } + rebuild(): void { this.publish(this.assembler.rebuildRegistry()) } dispose(): void { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index ce989f076e..ea7a3fe76f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -8,7 +8,7 @@ import type { ConversationSessionHeaderSlotProps, ConversationSessionSlotProps, } from '../contract/slots.ts' import { conversationPhase } from '../contract/snapshot.ts' -import type { ViewTab } from '../contract/views.ts' +import { resolveActiveView } from '../view-selection.ts' import css from './ConversationRoot.module.css' /** Full props composed from the strict session body contract. */ @@ -23,14 +23,6 @@ interface Breadcrumb { readonly subagent: boolean } -const DEFAULT_VIEW_ID = 'chat' - -/** Resolve a persisted selection, then registered Chat, without choosing another View. */ -function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined { - const selected = selectedId === null ? undefined : tabs.find(view => view.id === selectedId) - return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) -} - function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] { const chain: Breadcrumb[] = [] const seen = new Set() @@ -65,8 +57,8 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum * @returns the hidden blank-session header or visible title and tabs. */ export function ConversationSessionHeader({ - sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, actions, - renderSlot, open, t, + sessionId, useSession, useSessions, useConversation, useConversationViews, useStore, + renderSlot, open, selectView, t, }: ConversationSessionHeaderProps) { const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) @@ -151,7 +143,7 @@ export function ConversationSessionHeader({ role="tab" aria-selected={viewTab.id === active?.id} className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)} - onClick={() => { actions.setView(viewTab.id) }} + onClick={() => { selectView(viewTab.id) }} > {viewTab.label} @@ -172,7 +164,7 @@ export function ConversationSessionHeader({ */ export function ConversationSession({ useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, - renderSlot, bindDraftMirror, + renderSlot, bindDraftMirror, openView, }: ConversationSessionProps) { const tabs = useConversationViews(value => value) const selectedId = useStore(s => s.view) @@ -196,7 +188,7 @@ export function ConversationSession({
{active !== undefined && renderSlot('conversation.view', { viewRequest, - openView: actions.openView, + openView, completeViewRequest: actions.completeViewRequest, }, { only: active.id })}
diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 6cbbb9523b..244c6399a2 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -1,7 +1,10 @@ /** Per-session Conversation store shared by the shell body and header. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ConversationStoreState } from './contract/views.ts' +const CONVERSATION_STORE_KEY = 'dsh.conversation' + /** Declared write set for the Conversation shell. */ type ConversationActions = { setDraft: (draft: ConversationStoreState, text: string) => void @@ -17,7 +20,7 @@ type ConversationActions = { export function createConversationStore(): EngineStoreHandle { return defineStore({ init: (): ConversationStoreState => ({ draft: '', view: null, viewRequest: null }), - persist: 'dsh.conversation', + persist: CONVERSATION_STORE_KEY, actions: { setDraft: (d, text: string) => { d.draft = text }, setView: (d, view: string) => { d.view = view }, @@ -29,3 +32,21 @@ export function createConversationStore(): EngineStoreHandle view.id === selectedId) + return selected ?? tabs.find(view => view.id === DEFAULT_VIEW_ID) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index fb47038965..a767f54708 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -9,7 +9,7 @@ import { import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, type ComposerBarInjected, type ConversationInjected, - type ConversationSessionInjected, type ViewTab, + type ConversationSessionHeaderInjected, type ConversationSessionInjected, type ViewTab, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -51,7 +51,7 @@ async function bench() { const feature = await runtime.mount({ inject: [...inject], apply }) runtime.renderRoot() - const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') => + const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') => runtime.slots.entries(key)[0]! const conversationApi = (id: SessionId) => { const entry = entryOf('conversation.session') @@ -66,6 +66,15 @@ async function bench() { const entry = entryOf('conversation') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id) } + const headerApi = (id: SessionId) => { + const entry = entryOf('conversation.session.header') + const instance = runtime.storeOf('conversation.session.header', id) as ConversationInstance + const injected = (entry.inject as unknown as ( + sessionId: SessionId, + actions: ConversationActions, + ) => ConversationSessionHeaderInjected)(id, instance.actions) + return { instance, injected } + } const composerApi = (id: SessionId | undefined) => { const entry = entryOf('conversation.composer.bar') return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id) @@ -77,7 +86,7 @@ async function bench() { const viewSource = (id: SessionId): ObservableSnapshot => conversationApi(id).injected.hooks.conversationViews return { - runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi, + runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, inputApi, viewSource, sessionFake, connectWorkspace, } } @@ -87,11 +96,79 @@ describe('Conversation inject API', () => { const b = await bench() const { injected } = b.conversationApi(ROOT) expect(b.sessionFake.loadOlder).not.toHaveBeenCalled() - expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror']) + expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror', 'openView']) expect(b.viewSource(ROOT).getSnapshot()).toEqual([]) await b.runtime.dispose() }) + it('activates a target before committing an explicit View selection', async () => { + const b = await bench() + const binding = b.runtime.ctx.uiConversation.binding(ROOT) + const activate = vi.spyOn(binding, 'activate') + const removeChat = b.slots.register( + { name: 'conversation.view', id: 'chat', order: 0 }, + (() => null) as never, + ) + const removeTrajectory = b.slots.register( + { name: 'conversation.view', id: 'trajectory', order: 10 }, + (() => null) as never, + ) + await Promise.resolve() + activate.mockClear() + + const body = b.conversationApi(ROOT) + body.injected.openView('trajectory', 'call-1') + expect(activate).toHaveBeenLastCalledWith('trajectory') + expect(body.instance.store.getSnapshot()).toMatchObject({ + view: 'trajectory', + viewRequest: { view: 'trajectory', focus: 'call-1' }, + }) + + const header = b.headerApi(ROOT) + header.injected.selectView('chat') + expect(activate).toHaveBeenLastCalledWith('chat') + expect(header.instance.store.getSnapshot().view).toBe('chat') + + removeTrajectory() + removeChat() + await b.runtime.dispose() + }) + + it('restores the selected View when a cached Session becomes current', async () => { + const b = await bench() + const binding = b.runtime.ctx.uiConversation.binding(ROOT) + const activate = vi.spyOn(binding, 'activate') + const removeChat = b.slots.register( + { name: 'conversation.view', id: 'chat', order: 0 }, + (() => null) as never, + ) + let removeCustom: (() => void) | undefined + try { + await b.runtime.flush() + localStorage.setItem(`dsh.conversation.${ROOT}`, JSON.stringify({ + draft: '', view: 'custom', viewRequest: null, + })) + + b.runtime.ctx.uiSession.adapter.resolve(ROOT) + expect(activate).toHaveBeenLastCalledWith('chat') + activate.mockClear() + + removeCustom = b.slots.register( + { name: 'conversation.view', id: 'custom', order: 10 }, + (() => null) as never, + ) + await b.runtime.flush() + expect(activate).not.toHaveBeenCalled() + + await b.runtime.sessions.setCurrent(ROOT) + expect(activate).toHaveBeenLastCalledWith('custom') + } finally { + removeCustom?.() + removeChat() + await b.runtime.dispose() + } + }) + it('submits through the provided input machine and mirrors accepted draft edits', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index db5b0ebdb5..2c5fa7c17c 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -161,6 +161,18 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + it('reports a replacement only when an active target has a registered builder', () => { + const assembler = new RuntimeConversationNodeAssembler( + new TestEventDefinitions([]), + new TestViewDefinitions([]), + ) + + expect(assembler.activateTarget('registered-later')).toBe(false) + assembler.replaceWindow([], false) + + expect(assembler.flush()).toBe(false) + }) + it('updates only active targets and never deactivates one after first use', () => { type State = { readonly updates: number } const definition = ( diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index e1dcfceb23..63b2c22146 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -246,7 +246,7 @@ describe('Conversation registries', () => { rebuild.mockRestore() }) - it('activates each target on first use and keeps it active after unsubscribe', async () => { + it('activates each target on explicit selection or first use and never deactivates it', async () => { const { uiConversation, binding, views } = await bootRegistries() const chat = viewDefinition('chat') const trajectory = viewDefinition('trajectory') @@ -262,6 +262,9 @@ describe('Conversation registries', () => { expect(createChat).not.toHaveBeenCalled() expect(createTrajectory).not.toHaveBeenCalled() + conversation.activate('chat') + expect(createChat).toHaveBeenCalledOnce() + const unsubscribeChat = chatSource.subscribe(vi.fn()) const trajectoryListener = vi.fn() const unsubscribeTrajectory = trajectorySource.subscribe(trajectoryListener) diff --git a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts index 6ba0b43f4b..b9e7857721 100644 --- a/packages/client/ui-conversation/tests/conversation-store.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-store.client.spec.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest' -import { createConversationStore } from '../src/client/stores.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createConversationStore, readConversationViewPreference } from '../src/client/stores.ts' const KEY = 'dsh.conversation' @@ -54,4 +55,14 @@ describe('createConversationStore', () => { first.actions.setDraft('only first') expect(second.store.getSnapshot().draft).toBe('') }) + + it('reads only a usable persisted View preference', () => { + const sessionId = 'sess-1' as SessionId + const store = createConversationStore().create(sessionId) + store.actions.setView('trajectory') + expect(readConversationViewPreference(sessionId)).toBe('trajectory') + + localStorage.setItem(`${KEY}.${sessionId}`, '{invalid') + expect(readConversationViewPreference(sessionId)).toBeNull() + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 1d3f4159f0..3abf6f1381 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -203,6 +203,7 @@ function mount( actions={store.actions} renderSlot={renderSlot as never} open={open} + selectView={(view) => { store.actions.setView(view) }} t={t} /> ) @@ -227,6 +228,7 @@ function mount( actions={store.actions} renderSlot={renderSlot as never} bindDraftMirror={write => wiring.bindMirror(write)} + openView={(view, focus) => { store.actions.openView(view, focus) }} /> ) } @@ -433,8 +435,7 @@ describe('ConversationRoot resident composer', () => { { ...workspace('second'), title: 'Selected Folder' }, ], ) - // Hero chrome present, view ring absent; scroll host already wraps the - // resident composer so the blank → active flip does not remount it. + // Hero chrome is present and the selected View slot remains absent. const host = b.view.container.querySelector('[data-conversation-scroll]') const header = b.view.container.querySelector('header') expect(host).not.toBeNull() diff --git a/packages/client/ui-conversation/tests/view-selection.client.spec.ts b/packages/client/ui-conversation/tests/view-selection.client.spec.ts new file mode 100644 index 0000000000..cc0cd8af09 --- /dev/null +++ b/packages/client/ui-conversation/tests/view-selection.client.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { resolveActiveView } from '../src/client/view-selection.ts' +import type { ViewTab } from '../src/client/contract/views.ts' + +describe('resolveActiveView', () => { + it('resolves the preferred registered View and Chat fallback', () => { + const tabs: readonly ViewTab[] = [ + { id: 'chat', label: 'Chat' }, + { id: 'custom', label: 'Custom' }, + ] + + expect(resolveActiveView(tabs, 'custom')?.id).toBe('custom') + expect(resolveActiveView(tabs, 'removed')?.id).toBe('chat') + expect(resolveActiveView(tabs, null)?.id).toBe('chat') + }) + + it('does not choose an arbitrary registered View', () => { + expect(resolveActiveView([{ id: 'custom', label: 'Custom' }], null)).toBeUndefined() + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index dd3971bbb8..463c84e07e 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -264,6 +264,7 @@ async function bench(snapshot = historySnapshot(NODES)) { } const binding: ConversationBinding = { snapshot: conversationStore, + activate: () => {}, target: target => targetSources[target], } vi.spyOn(uiConversation, 'binding').mockReturnValue(binding) @@ -389,6 +390,7 @@ function mount(fixture: Awaited>) { actions={conversation.actions} renderSlot={() => null} open={vi.fn()} + selectView={conversation.actions.setView} t={tConversation} /> >) { actions={conversation.actions} renderSlot={renderSlot} bindDraftMirror={() => () => {}} + openView={conversation.actions.openView} /> , ) From 1dd3e60f5043420909d9fd9b3f59607f03ca906c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 31 Aug 2026 17:22:25 +0800 Subject: [PATCH 17/43] fix(web): make streaming code fences incremental --- ...20-web-streaming-fence-highlight.i18n.yaml | 4 +- ...026-08-20-web-streaming-fence-highlight.md | 17 +- ...-08-20-web-streaming-fence-highlight.zh.md | 17 +- ...8-25-loaded-turn-chat-navigation.i18n.yaml | 4 +- .../2026-08-25-loaded-turn-chat-navigation.md | 4 +- ...26-08-25-loaded-turn-chat-navigation.zh.md | 4 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 8 + packages/client/ui-chat/README.zh.md | 8 + .../ui-chat/src/client/chat/ChatView.tsx | 8 +- .../ui-chat/tests/chat-view.client.spec.tsx | 52 ++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 5 +- packages/client/ui-primitives/README.zh.md | 5 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 94 +++++-- .../src/markdown/MarkdownText.tsx | 6 +- .../ui-primitives/src/markdown/highlight.ts | 92 ++++--- .../ui-primitives/src/markdown/incremental.ts | 256 +++++++++++++++++- .../markdown-incremental.client.spec.tsx | 121 +++++++++ .../streaming-code-block.client.spec.tsx | 42 ++- 20 files changed, 649 insertions(+), 106 deletions(-) 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 index 2139112dec..bd978d1920 100644 --- 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 @@ -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-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 +2026-08-20-web-streaming-fence-highlight.md: aa44a8c7ea91ff4913d92449d62a6ebf9406e922 +2026-08-20-web-streaming-fence-highlight.zh.md: 88bae36928ac8d00960e529addeb4f30240e3579 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 index ccc961da1f..aa44a8c7ea 100644 --- 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 @@ -10,17 +10,18 @@ While a reply streamed, `MarkdownText` stripped the fence language before `CodeB ## Decision -Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged. +Streaming fences parse, tokenize, and reconcile from retained frontiers; the settled output 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.
+- **`IncrementalMarkdownParser`** (`packages/client/ui-primitives/src/markdown/incremental.ts`) recognizes a parser-confirmed final unclosed top-level fence after the ordinary tail parse. Completed content remains in the retained `code` node; only the last completed line and current partial line pass through the caller's GFM grammar, preserving its newline, indentation, CRLF, and value semantics without re-parsing the fence prefix. A closing delimiter, non-append input, nested/container fence, or ambiguous reconstruction returns to the ordinary tail parse.
+- **`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`); `updateFrame` publishes only newly completed lines plus the still-growing last line, while the compatibility `update` method materializes the complete result. Per-chunk tokenization 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.
+- **`CodeBlock`** renders the delta frames as a `pre.shiki.css-variables` React tree with the same attributes and token spans shiki's HTML emits. Completed lines seal into fixed-size React fragments; later updates reuse those fragment elements and reconcile only a bounded pending group plus the mutable tail. 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. Group size is an internal reconciliation unit, not a deployment policy or content limit.
+- **`render.tsx` and `MarkdownText`** pass `lang` and `context.streaming` to fences and key both streaming and settled top-level blocks by source offset. 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 `
`. `` ```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.
+The final full-document parse still resolves document-wide references and math. When that parse produces the same fence code and language, the source-offset key preserves its `CodeBlock` instance and the component reuses the complete streamed React tree; cold settled fences continue through `highlightToHtml`.
 
 ## 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.
+Package tests bound the grammar input accumulated across 800 open-fence lines, compare each incremental result with a full parse, and cover indented delimiters, CRLF split across chunks, closure fallback, and non-append reset. Highlighter tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; delta identity and reset/lazy paths; fixed-group DOM retention; streamed-to-settled DOM identity; 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
 
@@ -28,10 +29,12 @@ Package tests cover incremental/from-scratch equivalence across multiline gramma
 
 **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 "识别语言后即可增量高亮".
 
+**Keep only a fixed window of highlighted lines and turn the older prefix into plain text.** This bounds live token DOM and can reduce layout further, but changes already rendered content, complicates selection across the window boundary, and makes a tunable presentation policy part of `CodeBlock`. Retained parser, tokenizer, and React frontiers remove the avoidable repeated work without discarding colors; the complete token DOM remains an explicit limitation rather than a hidden semantic change.
+
 **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.
+Streaming code is readable as it arrives: tokens color as soon as the language is known; completed top-level fence content neither re-parses nor re-tokenizes; sealed React groups keep their elements and DOM; and settlement preserves the highlighted tree. 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 retained DOM still grows with final token count, so browser style and layout work is not length-independent. Nested/container fences use the ordinary tail parser, and the still-growing last line re-tokenizes per chunk; a pathological single-line fence therefore remains the worst case.
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
index e5d29545bf..88bae36928 100644
--- 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
@@ -10,17 +10,18 @@ Status: implemented
 
 ## Decision
 
-流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+流式围栏从保留的解析、tokenize 与 reconcile 前沿继续推进;定稿输出保持不变。
 
-- **`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 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+- **`IncrementalMarkdownParser`**(`packages/client/ui-primitives/src/markdown/incremental.ts`)会在普通尾部解析后识别经 parser 确认、位于末尾且未闭合的顶层 fence。已完成内容保留在既有 `code` node 中;只有最后一个已完成行与当前未完成行再次进入调用方的 GFM grammar,因此无需重新解析 fence 前缀,也能保留其换行、缩进、CRLF 与 value 语义。出现闭合分隔符、非追加输入、嵌套/容器内 fence 或无法明确重建时,会回到普通尾部解析。
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`);`updateFrame` 只发布新完成行与仍在增长的最后一行,兼容方法 `update` 则物化完整结果。每分片 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 自身的行切分一致。
+- **`CodeBlock`** 把增量 frame 渲染为带有 shiki HTML 同款属性与 token span 的 `pre.shiki.css-variables` React 树。已完成行会封入固定大小的 React fragment;后续更新复用这些 fragment element,只 reconcile 一个有界的待完成分组与可变尾部。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。分组大小只是内部 reconcile 单元,不是部署策略或内容上限。
+- **`render.tsx` 与 `MarkdownText`** 向围栏传递 `lang` 与 `context.streaming`,并让流式和定稿的顶层 block 都按源偏移设置 key。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 `
`。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
 
-定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+最终的全量文档解析仍会解决跨文档引用与数学语法。当该解析产生相同的 fence 代码与语言时,源偏移 key 会保留其 `CodeBlock` 实例,组件则复用完整的流式 React 树;冷启动的定稿 fence 继续使用 `highlightToHtml`。
 
 ## 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 树与可见语言横幅取代纯文本臂。
+包测试会约束 800 行未闭合 fence 的累计 grammar 输入量、逐次比较增量结果与全量解析,并覆盖缩进分隔符、跨分片 CRLF、闭合回退与非追加重置。高亮测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,delta 标识与重置/懒加载路径,固定分组的 DOM 保留,从流式到定稿的 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
 
@@ -28,10 +29,12 @@ Status: implemented
 
 **流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
 
+**只保留固定窗口内的高亮行,并把更早的前缀转成纯文本。** 这能限制流式 token DOM 并进一步降低布局成本,但会改变已经渲染的内容、让跨窗口边界的选择更复杂,还会把可调的展示策略塞进 `CodeBlock`。保留解析、tokenize 与 React 前沿可以在不丢颜色的情况下消除可避免的重复工作;完整 token DOM 被明确记录为限制,而不是隐藏的语义变化。
+
 **把高亮移到 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——与增量块解析器对单个巨型块接受的是同一退化类。
+流式代码随到达即可读:语言一经识别 token 即着色;顶层 fence 的已完成内容不再重新解析或 tokenize;封存的 React 分组保留其 element 与 DOM;定稿也会保留高亮树。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。保留的 DOM 仍随最终 token 数增长,因此浏览器 style 与 layout 工作量并非与长度无关。嵌套/容器内 fence 使用普通尾部解析器,仍在增长的最后一行则会每分片重新 tokenize;病态的单行超长 fence 因而仍是最坏情况。
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
index 0ba01f1eeb..2fd847ed20 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.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-25-loaded-turn-chat-navigation.md
-2026-08-25-loaded-turn-chat-navigation.md: 5d9d93b07f7a8c527bf7376bf111c6a709afa4d1
-2026-08-25-loaded-turn-chat-navigation.zh.md: 21dba024710b306848fee9bc4fe1f16913c475b3
+2026-08-25-loaded-turn-chat-navigation.md: b01e64d23fbc87f61c0de5b32cdf293c8d0afa94
+2026-08-25-loaded-turn-chat-navigation.zh.md: 85397c4a33cbc4ff1a08d253845f0112fe9b051b
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
index 5d9d93b07f..b01e64d23f 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md
@@ -18,7 +18,7 @@ The rail renders the complete loaded Turn set with a 10px natural interval and n
 
 The rail sits against the scrollport's right edge and centers on the band the sticky composer leaves visible. That band is the scrollport's own height minus the seat's, so ConversationRoot publishes `--dsh-conversation-viewport-height` beside the `--dsh-composer-height` it already measures on the same element, and the rail centers on their difference instead of a viewport height that ignores the Session header.
 
-The active mark follows a reading line near the top of the shared Chat scrollport. A scroll frame resolves the owning Turn with one hit test at that line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`; reaching the bottom selects the final loaded Turn. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
+The active mark follows a reading line near the top of the shared Chat scrollport. A pinned frame selects the final loaded Turn from scroll distance before reading any row geometry; streaming and other observed height changes can therefore follow the floor without a hit test or scan. Away from the floor, a scroll frame resolves the owning Turn with one hit test at the reading line, falling back to a single row scan where layout cannot answer, so cost does not grow with the number of marks. Flow-height changes that move rows across the line without a scroll event resync through the existing column observer. Scroll updates are coalesced with `requestAnimationFrame`. Activating a mark computes the target node's position in the existing scroll coordinate system, moves that same scrollport, and records the resulting Chat scroll-restoration anchor.
 
 Every Turn remains an accessible button even when dense marks visually overlap. The rail maps pointer height to the nearest loaded Turn, while keyboard focus and activation operate the individual buttons. Hover and focus show a compact prompt-and-response preview, the active mark is longer and darker, the rail is hidden when the Chat container is at most 900px wide, and reduced-motion preferences disable redistribution and mark-entry animation.
 
@@ -42,4 +42,4 @@ Desktop-width Chat views can jump among all currently loaded Turns and inspect a
 
 ## Testing
 
-Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, and percentage redistribution after prepend. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
+Builder tests pin the accumulated projection, the bounded preview, and preview freshness under an in-place chunk update. Component tests pin the published items, accessible previews, scroll-coordinate jumps, DOM identity, percentage redistribution after prepend, and a pinned `ResizeObserver` update that rejects every row-geometry read. The long-interaction Chromium scenario pins the real paginated boundary, prompt completion after `Load earlier`, stable-mark movement, keyboard activation, active-state update, and the narrow-container hide. The multi-Turn recorded Web snapshot includes the navigation landmark and buttons.
diff --git a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
index 21dba02471..85397c4a33 100644
--- a/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md
@@ -18,7 +18,7 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
 
 轨道紧贴滚动视口右缘,并在粘性输入区之外的可见区间内垂直居中。该区间等于滚动视口自身高度减去输入区高度,因此 ConversationRoot 在同一元素上除已有的 `--dsh-composer-height` 外再发布 `--dsh-conversation-viewport-height`,轨道按两者之差居中,而不是按忽略 Session 头部的视口高度居中。
 
-活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。每个滚动帧用一次命中测试解析该行所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。图片加载、工具卡展开等不产生滚动事件的高度变化,通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并;到达底部时选择最后一个已加载 Turn。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
+活跃刻度跟随共享 Chat 滚动区顶部附近的阅读线。跟随底部的 frame 会先按滚动距离选中最后一个已加载 Turn,不读取任何行几何;流式输出及其他被 observer 捕获的高度变化因此无需命中测试或扫描即可追随底部。离开底部后,每个滚动 frame 用一次命中测试解析阅读线所属 Turn,布局无法作答时退化为一次行扫描,成本不随刻度数量增长。不产生滚动事件却让行跨过阅读线的高度变化通过既有的 column observer 重新同步。滚动更新由 `requestAnimationFrame` 合并。激活刻度会在现有滚动坐标系中计算目标 node 的位置,移动同一个滚动区,并记录由此产生的 Chat 滚动恢复锚点。
 
 即使密集刻度在视觉上重叠,每个 Turn 仍是可访问的按钮。轨道把指针高度映射到最近的已加载 Turn,键盘聚焦和激活则作用于各个按钮。悬停或聚焦显示紧凑的问题与回复预览,活跃刻度更长、更深;Chat 容器宽度不超过 900px 时隐藏轨道,用户偏好减少动态效果时关闭重排和刻度入场动画。
 
@@ -42,4 +42,4 @@ Chat snapshot 构建层为当前已加载且含可见 transcript node 的每个
 
 ## 测试
 
-构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份以及前插后的百分比重排。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
+构建层测试固定累积投影、预览截断,以及原地 chunk 更新后的预览新鲜度。组件测试固定已发布条目、可访问预览、滚动坐标跳转、DOM 身份、前插后的百分比重排,以及拒绝任何行几何读取的底部跟随 `ResizeObserver` 更新。长交互 Chromium 场景固定真实分页边界、`加载更早` 后补齐问题、稳定刻度移动、键盘激活、活跃状态更新与窄容器隐藏。多 Turn 的 Web 录制快照包含导航 landmark 和按钮。
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 1e22b05602..d7a5f88cac 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: 9fe462316e6ab6d664a43b9fc481e07b01eda093
-README.zh.md: bad8a9143cd0307cdc5edb3e66bb9984cd5ca2be
+README.md: 1b24e6bb6825826a97a22a32f43d0210a2f4c6ad
+README.zh.md: 86596bc73ffd1b41867af6559739ab5426adb7b1
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 9fe462316e..1b24e6bb68 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -15,6 +15,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
 - [System prompt row](#system-prompt-row)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
+- [Scroll ownership](#scroll-ownership)
 - [Model Experience](#model-experience)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
 - [Dev Note](#dev-note)
@@ -42,6 +43,13 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
 
 -----
 
+
+## Scroll ownership
+
+Chat restores semantic anchors across history prepend and renderer remounts. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn ([loaded-Turn navigation](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md)).
+
+-----
+
 
 ## Model Experience
 
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index bad8a9143c..86596bc73f 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -15,6 +15,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
 - [系统提示词行](#system-prompt-row)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
+- [滚动归属](#scroll-ownership)
 - [模型体验](#model-experience)
 - [已知限制与暂缓事项](#known-limitations-and-deferred-work)
 - [开发备注](#dev-note)
@@ -42,6 +43,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
 
 -----
 
+
+## 滚动归属
+
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn([已加载 Turn 导航](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md))。
+
+-----
+
 
 ## 模型体验
 
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index b2b07d4229..517dccfeb3 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -321,6 +321,11 @@ export function ChatView({
       return
     }
     const el = scrollerOf(local)
+    if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
+      const latest = turnNavigationItems.at(-1)?.turn ?? first.turn
+      setActiveTurn(current => current === latest ? current : latest)
+      return
+    }
     const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2)
     const reading = turnAtLine(local, readingLine)
     // No row reaches the line yet: the flow head still owns the mark. Otherwise
@@ -333,9 +338,6 @@ export function ChatView({
         next = item.turn
       }
     }
-    if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
-      next = turnNavigationItems.at(-1)?.turn ?? next
-    }
     setActiveTurn(current => current === next ? current : next)
   }, [turnNavigationItems])
 
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index d95a3a4099..eccacecc0d 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -2179,6 +2179,58 @@ describe('ChatView', () => {
     expect(observe).toHaveBeenCalledTimes(1)
   })
 
+  it('pinned dynamic-height updates select the latest Turn without reading row geometry', () => {
+    let notify: (() => void) | undefined
+    let nextFrame = 0
+    const frames = new Map()
+    vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+      nextFrame += 1
+      frames.set(nextFrame, callback)
+      return nextFrame
+    })
+    vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
+    class ResizeObserverStub {
+      constructor(callback: ResizeObserverCallback) {
+        notify = () => { callback([], this as unknown as ResizeObserver) }
+      }
+
+      observe = vi.fn()
+      disconnect = vi.fn()
+    }
+    vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+    const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
+      .mockReturnValue({ top: 0, bottom: 40 } as DOMRect)
+    const h = makeHarness({
+      nodes: [
+        userInTurn(1, 'first', 1), assistant(2, 'first answer', 1),
+        userInTurn(4, 'second', 2), assistant(5, 'second answer', 2),
+      ],
+      turnEnds: new Map([[1, 3], [2, 6]]),
+    })
+    const view = render()
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    const metrics = installScrollMetrics(scroller, 1_000, 300)
+    scroller.scrollTop = 700
+    act(() => {
+      const pending = [...frames.values()]
+      frames.clear()
+      for (const callback of pending) callback(0)
+    })
+    rect.mockClear()
+
+    metrics.setHeight(1_200)
+    act(() => { notify?.() })
+    act(() => {
+      const pending = [...frames.values()]
+      frames.clear()
+      for (const callback of pending) callback(0)
+    })
+
+    expect(scroller.scrollTop).toBe(900)
+    expect(view.getByRole('button', { name: '跳转到第 2 轮' }).getAttribute('aria-current')).toBe('true')
+    expect(rect).not.toHaveBeenCalled()
+  })
+
   it('entering the at-bottom threshold does not snap the remaining scroll distance', () => {
     const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
     const view = render()
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index d48604ea73..9fcd055b42 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: 42c1110e8735dd2191c8b7a2e4dcc1f2b9b938bc
-README.zh.md: 9f3c06cacebb7a596204dbe1c8e00a549ce1fcae
+README.md: 525c2c01ec3f3aa3f6146beb3b3b7a6209998a85
+README.zh.md: b2758c5de1f63d6e211dc462c2386bb9e59e4826
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 42c1110e87..525c2c01ec 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -33,7 +33,7 @@ Compose feature UI from these atoms whenever the web client needs a standard con
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks and highlights a growing fence from saved Shiki grammar state; the final render uses the same span tree ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content.
 
 ### Localizing copy
 
@@ -63,7 +63,7 @@ The package is one separation: presentational React atoms with zero Cordis and z
 
 ### Streaming markdown
 
-While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A growing fenced block tokenizes completed text from saved Shiki grammar state plus the unfinished last line; completed lines retain their DOM, and the settled render uses the same span tree. The settled full parse at finalize also resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
+While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
 
 ### Geometry and overflow
 
@@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request.
 These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
 - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
+- **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
 - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
 - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
 - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, and error.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 9f3c06cace..b2758c5de1 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -33,7 +33,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块,并从保存的 Shiki grammar state 为不断增长的 fence 增量高亮;最终渲染使用相同的 span 树([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。
 
 ### 本地化文案
 
@@ -63,7 +63,7 @@ kind: "package-library"
 
 ### 流式 markdown
 
-回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。不断增长的 fenced block 会从已保存的 Shiki grammar state 加上尚未完成的最后一行继续分词;已完成行保留其 DOM,定稿渲染则使用相同的 span 树。定稿时的全量解析还会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
+回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
 
 ### 几何与溢出
 
@@ -103,6 +103,7 @@ kind: "package-library"
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
+- **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index ba9cd43392..04bc7c2062 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -5,7 +5,7 @@ import { writeClipboard } from '../clipboard.ts'
 import {
   StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
 } from './highlight.ts'
-import type { HighlightSpan } from './highlight.ts'
+import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
 import css from './CodeBlock.module.css'
 
 export interface CodeBlockProps {
@@ -16,9 +16,10 @@ export interface CodeBlockProps {
   /**
    * 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.
+   * appended text and keeps completed line groups (and DOM) untouched. The
+   * caller must keep the component instance stable across growth (a
+   * stream-stable React key); an unchanged streamed fence also retains that
+   * tree when it settles. Cold settled callers get shiki's HTML.
    */
   streaming?: boolean | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
@@ -41,49 +42,92 @@ const SHIKI_PRE_PROPS = {
   tabIndex: 0,
 } as const
 
+/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
+const STREAMING_LINE_GROUP_SIZE = 32
+
+function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
+  return (
+    
+      {index > 0 && '\n'}
+      
+        {line.map((span, spanIndex) => {span.text})}
+      
+    
+  )
+}
+
 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(
-    () => (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 lineCacheRef = useRef<{
+    code: string
+    lang: string | undefined
+    generation: number
+    frame: StreamingHighlightFrame
+    groups: ReactNode[]
+    pending: ReactNode[]
+    nextLine: number
+    body: ReactNode
+  } | null>(null)
+  const settledRef = useRef(false)
   const streamedBody = useMemo(() => {
     if (streaming !== true) {
+      const previous = lineCacheRef.current
+      if (previous !== null && previous.code === trimmed && previous.lang === lang) {
+        settledRef.current = true
+        return previous.body
+      }
       sessionRef.current = null
       lineCacheRef.current = null
+      settledRef.current = true
       return undefined
     }
+    if (settledRef.current) {
+      sessionRef.current = null
+      lineCacheRef.current = null
+      settledRef.current = false
+    }
     sessionRef.current ??= new StreamingHighlightSession()
-    const lines = sessionRef.current.update(trimmed, lang)
-    if (lines === undefined) {
+    const frame = sessionRef.current.updateFrame(trimmed, lang)
+    if (frame === 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}
+ if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) { + return previous.body + } + const sameGeneration = previous?.generation === frame.generation + const groups = sameGeneration ? [...previous.groups] : [] + let pending = sameGeneration ? [...previous.pending] : [] + let nextLine = sameGeneration ? previous.nextLine : 0 + for (const line of frame.appended) { + pending.push(renderLine(line, nextLine)) + nextLine += 1 + if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue + const start = nextLine - pending.length + groups.push({pending}) + pending = [] + } + const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index)) + const tailGroup = {pending}{tail} + const body =
{groups}{tailGroup}
+ lineCacheRef.current = { + code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, + } + return body }, [streaming, trimmed, lang, loaded]) + const html = useMemo( + () => (streaming !== true && streamedBody === undefined ? highlightToHtml(trimmed, lang) : undefined), + [streaming, streamedBody, trimmed, lang, loaded], + ) const rootRef = useRef(null) const [copied, setCopied] = useState(false) diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 3a26bac424..0b19896979 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -43,7 +43,11 @@ function renderSettled( footnoteCounts: new Map(), } const blocks = wrapBlockChildren( - renderBlocks(root.children.map((node, index) => ({ node, key: index })), context), + renderBlocks(root.children.map((node, index) => ({ + node, + /* v8 ignore next -- parseFull uses parseGfm, which stamps every top-level node. */ + key: node.position?.start.offset ?? -(index + 1), + })), context), false, ) const section = renderFootnoteSection(context) diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 047f9d1139..d97d8d6c7f 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -333,10 +333,11 @@ function lineSpans(line: ThemedToken[]): HighlightSpan[] { * 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. + * completed line together with the grammar state after them; + * {@link updateFrame} reports only newly completed lines plus the still-growing + * last line, while {@link update} materializes the complete compatibility + * result. Per-call tokenization 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. */ @@ -352,12 +353,16 @@ export class StreamingHighlightSession { private lastCode: string | undefined private lastLang: string | undefined private lastResult: HighlightSpan[][] | undefined + private generation = 0 + private lastFrame: StreamingHighlightFrame | undefined private reset(resolved: string | undefined): void { this.resolved = resolved this.prefix = '' this.spans = [] this.state = undefined + this.generation += 1 + this.lastFrame = undefined } /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */ @@ -369,6 +374,43 @@ export class StreamingHighlightSession { }) } + /** + * Tokenize one update as a delta for a retained renderer. + * @param code - the fence text accumulated so far. + * @param lang - the language hint. + * @returns Newly completed lines plus the current tail, or `undefined` for the plain arm. + */ + updateFrame(code: string, lang: string | undefined): StreamingHighlightFrame | undefined { + if (code === this.lastCode && lang === this.lastLang && this.lastFrame !== undefined) { + return this.lastFrame + } + this.lastCode = code + this.lastLang = lang + this.lastResult = undefined + const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) + if (resolved === undefined || !ensureGrammar(resolved)) { + this.reset(undefined) + return undefined + } + if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved) + const firstNewLine = this.spans.length + const rest = code.slice(this.prefix.length) + const lastNewline = rest.lastIndexOf('\n') + if (lastNewline >= 0) { + const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline + const tokens = this.tokenize(resolved, rest.slice(0, grownEnd)) + 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.lastFrame = { + generation: this.generation, + appended: this.spans.slice(firstNewLine), + tail: this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans), + } + return this.lastFrame + } + /** * Tokenize the fence's current text into per-line highlighted runs; * `undefined` means the caller renders its plain fallback. Idempotent per @@ -385,39 +427,23 @@ export class StreamingHighlightSession { 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)] + const frame = this.updateFrame(code, lang) + if (frame === undefined) return undefined + this.lastResult = [...this.spans, ...frame.tail] return this.lastResult } } +/** One retained-renderer update from {@link StreamingHighlightSession.updateFrame}. */ +export interface StreamingHighlightFrame { + /** Changes whenever prior completed lines must be discarded. */ + readonly generation: number + /** Completed lines added since the preceding frame in this generation. */ + readonly appended: readonly HighlightSpan[][] + /** The still-growing final line or lines, replaced by the next frame. */ + readonly tail: readonly HighlightSpan[][] +} + /** * Tokenize `code` into per-line highlighted runs when `lang` maps to a * registered grammar; `undefined` means the caller renders its plain fallback. diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts index 18638a25b6..dc216e5155 100644 --- a/packages/client/ui-primitives/src/markdown/incremental.ts +++ b/packages/client/ui-primitives/src/markdown/incremental.ts @@ -4,19 +4,23 @@ * Re-parsing the whole accumulated document on every streaming chunk is * quadratic in the final reply length. CommonMark block parsing is line-based * and appended text can only reshape the parse frontier — the last top-level - * block (a paragraph becoming a setext heading or a table, a list continuing - * after a blank line, an unclosed fence swallowing lines) — so earlier blocks - * are final. This parser therefore freezes all but the trailing - * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail - * behind them: each source region is parsed O(1) times over the stream - * instead of once per chunk. + * block (a paragraph becoming a setext heading or a table, or a list + * continuing after a blank line) — so earlier blocks are final. This parser + * therefore freezes all but the trailing {@link UNSTABLE_TAIL_BLOCKS} blocks + * and re-parses only the source tail behind them. A final unclosed top-level + * fence cannot freeze as a block, so its completed content lines use a second + * frontier: only the last completed line and current partial line return + * through the caller's grammar. Each source region is therefore parsed a + * bounded number of times over the stream instead of once per chunk. * - * The freeze boundary comes from the parser's own `position` offsets, never - * from custom source scanning. The cut sits at the *end offset* of the last - * frozen block (not the next block's start): a following block's start offset - * excludes up to three spaces of insignificant leading indentation, which is - * harmless to drop, but cutting at the previous end also keeps the - * inter-block blank lines in the tail so the sliced source stays verbatim. + * The block freeze boundary comes from the parser's own `position` offsets. + * The cut sits at the *end offset* of the last frozen block (not the next + * block's start): a following block's start offset excludes up to three spaces + * of insignificant leading indentation, which is harmless to drop, but + * cutting at the previous end also keeps the inter-block blank lines in the + * tail so the sliced source stays verbatim. Fence scanning only recognizes a + * parser-confirmed code node and closing delimiter; ambiguous input returns to + * the normal tail parse. * * Known deviation, shared with any prefix-freeze scheme: micromark resolves * reference-style links and footnotes document-wide at parse time, so a @@ -24,7 +28,7 @@ * renders literally until the settled full parse self-heals it. */ -import type { Root, RootContent } from 'mdast' +import type { Code, Root, RootContent } from 'mdast' /** * Trailing blocks kept unstable. Appended text reshapes at most the last @@ -68,6 +72,103 @@ function blockKey(node: RootContent, base: number, index: number): number { return offset === undefined ? -(index + 1) : base + offset } +interface OpenFenceState { + readonly marker: '`' | '~' + readonly markerLength: number + readonly syntheticPrefix: string + readonly codeIndex: number + readonly frozen: readonly PositionedBlock[] + readonly tail: readonly PositionedBlock[] + readonly pendingStart: number + readonly valuePrefix: string + readonly end: { readonly line: number; readonly column: number; readonly offset: number } + readonly endedWithCarriageReturn: boolean +} + +/** Return the first line terminator at or after `start`, including a CRLF pair. */ +function lineTerminatorEnd(text: string, start: number): number | undefined { + for (let index = start; index < text.length; index += 1) { + const char = text[index] + if (char === '\n') return index + 1 + if (char === '\r') return text[index + 1] === '\n' ? index + 2 : index + 1 + } + return undefined +} + +/** + * Source prefix before the last completed line. Keeping that line beside the + * current partial line lets the grammar retain its trailing-newline semantics. + */ +function committableLinePrefixLength(text: string): number { + let previousEnd = 0 + let end = 0 + for (let index = 0; index < text.length; index += 1) { + const char = text[index] + if (char === '\n') { + previousEnd = end + end = index + 1 + continue + } + if (char !== '\r' || index + 1 >= text.length) continue + if (text[index + 1] === '\n') index += 1 + previousEnd = end + end = index + 1 + } + return previousEnd +} + +/** Exact source terminator ending a non-empty committable prefix. */ +function trailingLineTerminator(text: string): '\n' | '\r' | '\r\n' { + return text.endsWith('\r\n') ? '\r\n' : text.endsWith('\r') ? '\r' : '\n' +} + +/** Whether `text` contains a CommonMark closing fence on one of its logical lines. */ +function containsClosingFence(text: string, marker: '`' | '~', markerLength: number): boolean { + let start = 0 + while (start <= text.length) { + let end = start + while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end += 1 + const line = text.slice(start, end) + let indent = 0 + while (indent < 3 && line[indent] === ' ') indent += 1 + let run = indent + while (line[run] === marker) run += 1 + if (run - indent >= markerLength && /^[ \t]*$/.test(line.slice(run))) return true + if (end === text.length) return false + start = text[end] === '\r' && text[end + 1] === '\n' ? end + 2 : end + 1 + } + /* v8 ignore next -- each loop iteration returns at EOF or advances past a line terminator. */ + return false +} + +/** Advance an mdast point across one append while treating a split CRLF as one line ending. */ +function advancePoint( + point: OpenFenceState['end'], + appended: string, + precededByCarriageReturn: boolean, +): OpenFenceState['end'] { + let line = point.line + let column = point.column + let afterCarriageReturn = precededByCarriageReturn + for (const char of appended) { + if (char === '\n') { + if (!afterCarriageReturn) line += 1 + column = 1 + afterCarriageReturn = false + continue + } + if (char === '\r') { + line += 1 + column = 1 + afterCarriageReturn = true + continue + } + column += 1 + afterCarriageReturn = false + } + return { line, column, offset: point.offset + appended.length } +} + /** * Append-only incremental parser over a caller-supplied grammar. One instance * accumulates one streaming document; non-append input resets it. @@ -78,10 +179,127 @@ export class IncrementalMarkdownParser { private frozen: PositionedBlock[] = [] private generation = 0 private cached: IncrementalBlocks | null = null + private openFence: OpenFenceState | null = null /** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */ constructor(private readonly parse: (text: string) => Root) {} + /** Parse one unclosed-fence content slice through the caller's grammar. */ + private fenceValue(state: Pick, text: string): string | undefined { + const root = this.parse(`${state.syntheticPrefix}${text}`) + if (root.children.length !== 1) return undefined + const node = root.children[0] as RootContent + return node.type === 'code' ? node.value : undefined + } + + /** Recognize the parsed tail's final unclosed fence and prepare its incremental content frontier. */ + private openFenceState( + text: string, + base: number, + tail: readonly PositionedBlock[], + frozen: readonly PositionedBlock[], + ): OpenFenceState | null { + const codeIndex = tail.length - 1 + const block = tail[codeIndex] + if (block?.node.type !== 'code') return null + const node = block.node + const startOffset = node.position?.start.offset + const end = node.position?.end + if (startOffset === undefined || end?.offset === undefined) return null + /* v8 ignore next -- the caller's parse slice ends at text.length, so its final node ends there. */ + if (base + end.offset !== text.length) return null + const source = text.slice(base) + const previousLf = source.lastIndexOf('\n', startOffset - 1) + const previousCr = source.lastIndexOf('\r', startOffset - 1) + const lineStart = Math.max(previousLf, previousCr) + 1 + const terminatorEnd = lineTerminatorEnd(source, startOffset) + /* v8 ignore next -- a parser-confirmed fenced code node requires its opening line terminator. */ + if (terminatorEnd === undefined) return null + if (terminatorEnd === source.length && source.endsWith('\r')) return null + const openingLine = source.slice(lineStart, terminatorEnd).replace(/[\r\n]+$/, '') + const opening = /^( {0,3})(`{3,}|~{3,})/.exec(openingLine) + if (opening === null) return null + const indent = opening[1] as string + const run = opening[2] as string + /* v8 ignore next -- mdast positions a fenced code node at the matched delimiter after indentation. */ + if (lineStart + indent.length !== startOffset) return null + const marker = run[0] as '`' | '~' + const contentStart = base + terminatorEnd + const content = text.slice(contentStart) + if (containsClosingFence(content, marker, run.length)) return null + const syntheticPrefix = `${indent}${run}\n` + const stableLength = committableLinePrefixLength(content) + const stableValue = stableLength === 0 + ? '' + : this.fenceValue({ syntheticPrefix }, content.slice(0, stableLength)) + if (stableValue === undefined) return null + const pendingStart = contentStart + stableLength + const stableSource = content.slice(0, stableLength) + const valuePrefix = stableLength === 0 ? '' : `${stableValue}${trailingLineTerminator(stableSource)}` + const pendingValue = this.fenceValue({ syntheticPrefix }, text.slice(pendingStart)) + if (pendingValue === undefined || `${valuePrefix}${pendingValue}` !== node.value) return null + return { + marker, + markerLength: run.length, + syntheticPrefix, + codeIndex, + frozen, + tail, + pendingStart, + valuePrefix, + end: { line: end.line, column: end.column, offset: end.offset }, + endedWithCarriageReturn: text.endsWith('\r'), + } + } + + /** Extend a recognized unclosed fence without parsing its completed content prefix again. */ + private updateOpenFence( + state: OpenFenceState, + text: string, + previousText: string, + ): IncrementalBlocks | undefined { + const pending = text.slice(state.pendingStart) + if (containsClosingFence(pending, state.marker, state.markerLength)) return undefined + const pendingValue = this.fenceValue(state, pending) + if (pendingValue === undefined) return undefined + const stableLength = committableLinePrefixLength(pending) + const stableValue = stableLength === 0 + ? '' + : this.fenceValue(state, pending.slice(0, stableLength)) + if (stableValue === undefined) return undefined + // OpenFenceState is private and is installed only from this exact retained + // code entry; updates replace that entry with another positioned Code. + const block = state.tail[state.codeIndex] as PositionedBlock + const previousNode = block.node as Code & { position: NonNullable } + const end = advancePoint( + state.end, + text.slice(previousText.length), + state.endedWithCarriageReturn, + ) + const node: Code = { + ...previousNode, + value: `${state.valuePrefix}${pendingValue}`, + position: { start: previousNode.position.start, end }, + } + const tail = state.tail.map((entry, index) => index === state.codeIndex ? { ...entry, node } : entry) + const cached = { + frozen: state.frozen, + tail, + generation: this.generation, + } + this.openFence = { + ...state, + tail, + pendingStart: state.pendingStart + stableLength, + valuePrefix: stableLength === 0 + ? state.valuePrefix + : `${state.valuePrefix}${stableValue}${trailingLineTerminator(pending.slice(0, stableLength))}`, + end, + endedWithCarriageReturn: text.endsWith('\r'), + } + return cached + } + /** * Fold the current accumulated text and return the frozen/tail split. * Idempotent for identical input (the previous result is returned as-is), @@ -101,8 +319,19 @@ export class IncrementalMarkdownParser { this.prevText = '' this.tailStart = 0 this.frozen = [] + this.openFence = null this.generation += 1 } + const previousText = this.prevText + if (previousText !== '' && this.openFence !== null) { + const incremental = this.updateOpenFence(this.openFence, text, previousText) + if (incremental !== undefined) { + this.prevText = text + this.cached = incremental + return incremental + } + this.openFence = null + } this.prevText = text const base = this.tailStart const blocks = this.parse(text.slice(base)).children @@ -125,6 +354,7 @@ export class IncrementalMarkdownParser { key: blockKey(node, base, index), })) this.cached = { frozen: [...this.frozen], tail, generation: this.generation } + this.openFence = this.openFenceState(text, base, tail, this.cached.frozen) return this.cached } } diff --git a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx index f3624b96d4..d0d7a8a205 100644 --- a/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-incremental.client.spec.tsx @@ -117,6 +117,16 @@ describe('incremental streaming rendering', () => { live.unmount() settled.unmount() }) + + it('keeps a highlighted fence mounted across the final full-document parse', () => { + const doc = 'before.\n\n```ts\nconst answer = 42\n```\n\nafter.' + const live = render() + const line = live.container.querySelector('pre.shiki .line') + expect(line).not.toBeNull() + live.rerender() + expect(live.container.querySelector('pre.shiki .line')).toBe(line) + live.unmount() + }) }) describe('incremental parsing is actually in effect', () => { @@ -145,6 +155,25 @@ describe('incremental parsing is actually in effect', () => { expect(totalParsed).toBeLessThan(text.length * 5) }) + it('parses an open fence through bounded grammar slices as completed lines accumulate', () => { + const calls: string[] = [] + const recording = (text: string): Root => { + calls.push(text) + return parseGfm(text) + } + const parser = new IncrementalMarkdownParser(recording) + let text = '```ts\n' + let result = parser.update(text) + for (let index = 0; index < 800; index += 1) { + text += `const value${String(index)} = ${String(index)}\n` + result = parser.update(text) + } + const parsed = calls.reduce((sum, call) => sum + call.length, 0) + expect(Math.max(...calls.slice(10).map(call => call.length))).toBeLessThan(80) + expect(parsed).toBeLessThan(text.length * 4) + expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children[0]) + }) + it('shows the documented streaming fingerprint: a definition frozen earlier no longer resolves a new reference, and settling heals it', () => { const doc = [ '[ref]: https://example.com/target', @@ -197,6 +226,98 @@ describe('freeze dynamics around frontier-sensitive constructs', () => { expect(frozenCode?.type === 'code' && frozenCode.value).toContain('looks like a list') }) + it('keeps indented CRLF fence nodes equal to a fresh parse, then falls back when the fence closes', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + const opening = 'p1.\n\np2.\n\np3.\n\n ```ts\r\n' + const suffix = ' const a = 1\r\n const b = 2\r\n ```\r\nafter' + let text = '' + for (const char of `${opening}${suffix}`) { + text += char + const result = parser.update(text) + const actual = [...result.frozen, ...result.tail].at(-1) + const expected = parseGfm(text).children.at(-1) + expect(actual?.key).toBe(expected?.position?.start.offset) + expect(actual?.node.type).toBe(expected?.type) + if (actual?.node.type === 'code' && expected?.type === 'code') { + expect({ lang: actual.node.lang, meta: actual.node.meta, value: actual.node.value }) + .toEqual({ lang: expected.lang, meta: expected.meta, value: expected.value }) + } + } + }) + + it('preserves lone-CR fence lines and ignores indented code as a fence frontier', () => { + const parser = new IncrementalMarkdownParser(parseGfm) + let text = '```ts\rfirst\r' + parser.update(text) + text += 'second\rthird' + const result = parser.update(text) + expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1)) + + const indented = ' alpha\n beta\n' + const indentedResult = new IncrementalMarkdownParser(parseGfm).update(indented) + expect(indentedResult.tail.at(-1)?.node).toEqual(parseGfm(indented).children.at(-1)) + }) + + it('falls back to the full grammar tail when a custom grammar rejects fence slices', () => { + type Corruption = 'many' | 'paragraph' | 'mismatch' + const custom = (corruption: Corruption): ((text: string) => Root) => (text) => { + if (!text.startsWith('```\n')) return parseGfm(text) + if (corruption === 'many') return parseGfm('one\n\ntwo') + if (corruption === 'paragraph') return parseGfm('one') + const root = parseGfm(text) + const node = root.children[0] + if (node?.type === 'code') node.value += 'mismatch' + return root + } + const cases = [ + { corruption: 'many' as const, text: '```ts\nfirst' }, + { corruption: 'paragraph' as const, text: '```ts\nfirst' }, + { corruption: 'many' as const, text: '```ts\nfirst\nsecond\nthird' }, + { corruption: 'mismatch' as const, text: '```ts\nfirst' }, + ] + for (const { corruption, text } of cases) { + const result = new IncrementalMarkdownParser(custom(corruption)).update(text) + expect(result.tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1)) + } + + const positionless = new IncrementalMarkdownParser((text) => { + const root = parseGfm(text) + for (const node of root.children) delete node.position + return root + }).update('```ts\nfirst') + expect(positionless.tail.at(-1)?.node.type).toBe('code') + }) + + it('abandons an installed fence frontier when later custom-grammar slices fail', () => { + let syntheticCall = 0 + let reject: 'none' | 'first' | 'second' = 'none' + const custom = (text: string): Root => { + if (!text.startsWith('```\n')) return parseGfm(text) + syntheticCall += 1 + if (reject === 'first' && syntheticCall === 1) return parseGfm('one\n\ntwo') + if (reject === 'second' && syntheticCall === 2) return parseGfm('one\n\ntwo') + return parseGfm(text) + } + + const pendingParser = new IncrementalMarkdownParser(custom) + let text = '```ts\nfirst\nsecond' + pendingParser.update(text) + syntheticCall = 0 + reject = 'first' + text += ' tail' + expect(pendingParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1)) + + reject = 'none' + syntheticCall = 0 + const stableParser = new IncrementalMarkdownParser(custom) + text = '```ts\nfirst\nsecond' + stableParser.update(text) + syntheticCall = 0 + reject = 'second' + text += '\nthird\nfourth' + expect(stableParser.update(text).tail.at(-1)?.node).toEqual(parseGfm(text).children.at(-1)) + }) + it('a list can keep extending across blank lines until it freezes whole', () => { const parser = new IncrementalMarkdownParser(parseGfm) let text = 'intro.\n\nsecond.\n\nthird.\n\n- item a\n- item b\n' diff --git a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx index 6ed9e43732..3fe0847693 100644 --- a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx @@ -67,6 +67,19 @@ describe('StreamingHighlightSession', () => { expect(second?.[1]).not.toBe(first?.[1]) }) + it('reports only newly completed lines to a retained renderer', () => { + const session = new StreamingHighlightSession() + const first = session.updateFrame('const a = 1\nlet', 'ts') + const second = session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts') + expect(first?.appended).toHaveLength(1) + expect(first?.tail).toHaveLength(1) + expect(second?.appended).toHaveLength(1) + expect(second?.appended[0]?.map(span => span.text).join('')).toBe('let b = 2') + expect(second?.tail[0]?.map(span => span.text).join('')).toBe('// tail') + expect(second?.generation).toBe(first?.generation) + expect(session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts')).toBe(second) + }) + it('is idempotent per input: repeated calls return the identical result array', () => { const session = new StreamingHighlightSession() const result = session.update('const a = 1', 'ts') @@ -211,19 +224,46 @@ describe('CodeBlock streaming arm', () => { expect(view.container.querySelector('pre.shiki')?.textContent).toBe('const a = 1\nlet partial = 2\n// tail') }) + it('keeps completed line groups mounted while later groups grow', () => { + const code = (count: number) => Array.from({ length: count }, (_, index) => `const v${String(index)} = ${String(index)}`).join('\n') + const view = render() + const firstLine = view.container.querySelector('pre.shiki .line') + const thirtySecond = view.container.querySelectorAll('pre.shiki .line')[31] + view.rerender() + const lines = view.container.querySelectorAll('pre.shiki .line') + expect(lines).toHaveLength(80) + expect(lines[0]).toBe(firstLine) + expect(lines[31]).toBe(thirtySecond) + }) + + it('reuses an unchanged frame when an unrelated lazy grammar finishes loading', async () => { + const view = render() + const line = view.container.querySelector('pre.shiki .line') + expect(line).not.toBeNull() + const loader = new StreamingHighlightSession() + expect(loader.update('puts 1', 'ruby')).toBeUndefined() + await vi.waitFor(() => { expect(loader.update('puts 1', 'ruby')).toBeDefined() }, { timeout: 5_000 }) + expect(view.container.querySelector('pre.shiki .line')).toBe(line) + }) + it('streaming with an unknown language stays on the identical plain arm', () => { const view = render() expect(view.container.querySelector('pre.shiki')).toBeNull() expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy() }) - it('the settle swap (streaming to settled) preserves the code content', () => { + it('the settle transition preserves the highlighted DOM when the code is unchanged', () => { const code = 'const answer = 42\n' const view = render() + const streamedLine = view.container.querySelector('pre.shiki .line') const streamedText = view.container.querySelector('pre.shiki')?.textContent view.rerender() const settledText = view.container.querySelector('pre.shiki')?.textContent expect(streamedText).toBe('const answer = 42') expect(settledText).toBe(streamedText) + expect(view.container.querySelector('pre.shiki .line')).toBe(streamedLine) + view.rerender() + expect(view.container.querySelector('pre.shiki')?.textContent).toBe(streamedText) + expect(view.container.querySelector('pre.shiki .line')).toBe(streamedLine) }) }) From 2480bdf27ae13b2bf0554d7a357b4a2262c98fa9 Mon Sep 17 00:00:00 2001 From: Ziya Date: Mon, 31 Aug 2026 17:42:16 +0800 Subject: [PATCH 18/43] feat(web): clarify Workspace Write Chinese label (#3345) Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> --- apps/web/tests/access-confirmation.e2e.ts | 2 +- apps/web/tests/expected/settings-chrome/dialog.expected.md | 4 ++-- apps/web/tests/settings-chrome.e2e.ts | 4 ++-- packages/client/ui-conversation/src/client/locales.ts | 2 +- .../client/ui-conversation/tests/input-bar.client.spec.tsx | 6 +++--- packages/client/ui-permission-presets/README.i18n.yaml | 4 ++-- packages/client/ui-permission-presets/README.md | 2 +- packages/client/ui-permission-presets/README.zh.md | 2 +- packages/client/ui-permission-presets/src/client/locales.ts | 4 ++-- .../tests/browser-plugin.client.spec.ts | 2 +- .../tests/permission-presets-row.client.spec.tsx | 6 +++--- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index e5595c94a8..cd64d98110 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -50,7 +50,7 @@ describe('web e2e: Full access confirmation', () => { const access = page.locator('button[aria-label^="访问模式"]').first() await access.waitFor({ timeout: 10_000 }) - expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区') + expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:工作区内修改') await access.click() await page.getByRole('menuitem', { name: '完全权限' }).click() diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 9ff9301454..45ad145095 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -18,8 +18,8 @@ - img - text: 关闭 - text: 权限 选择新会话的默认权限模式 - - button "可写入工作区": - - text: 可写入工作区 + - button "工作区内修改": + - text: 工作区内修改 - img - text: 语言 - button "中文": diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 4b8fd7495f..f86f5c0964 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await trigger.getAttribute('aria-expanded')).toBe('true') // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') - await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) @@ -150,7 +150,7 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - const selector = dialog.getByRole('button', { name: '可写入工作区' }) + const selector = dialog.getByRole('button', { name: '工作区内修改' }) await selector.waitFor({ timeout: 10_000 }) await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await selector.click() diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 0e8ba381f4..296f95b33a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -56,7 +56,7 @@ export const zh = { 'settings.enter.queue': '排队发送', 'settings.enter.steer': '插话发送', 'access.preset.readOnly': '仅可查看', - 'access.preset.workspaceWrite': '可写入工作区', + 'access.preset.workspaceWrite': '工作区内修改', 'access.preset.fullAccess': '完全权限', 'access.confirm.title': '确认启用完全权限?', 'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index d616c34a43..94aec70bbf 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1335,11 +1335,11 @@ describe('command launcher chrome and control seats', () => { .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true) fireEvent.click(trigger) const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限']) + expect(items.map(o => o.textContent)).toEqual(['仅可查看', '工作区内修改', '完全权限']) fireEvent.click(items[1]!) // Optimistic pick + disable until admission resolves (command stub resolves true). const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement - expect(busy.textContent).toBe('可写入工作区') + expect(busy.textContent).toBe('工作区内修改') expect(busy.disabled).toBe(true) expect(command).toHaveBeenCalledWith('/permission workspace-write') await act(async () => {}) @@ -1413,7 +1413,7 @@ describe('command launcher chrome and control seats', () => { fireEvent.click(view.getByRole('checkbox')) fireEvent.click(view.getByRole('button', { name: '取消' })) expect(command).not.toHaveBeenCalled() - expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区') + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('工作区内修改') openConfirmation() expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index 44382511cf..5ae75b98f1 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/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-permission-presets/README.md -README.md: 6a82ebe45edb7e6055bf4c69a2e90bf21255696c -README.zh.md: bd5588b13cc55d856163ff3eadf04f8059b2f7a9 +README.md: a88c241b8d93228b19d40569f67b4311a550a7c7 +README.zh.md: a20d237f2a67f6f0aa085c3e0784a1e0f25c0c1c diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index 6a82ebe45e..a88c241b8d 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside the settings and commands packages; the permission r ### The picker -A pick submits the `/permission ` command line. The argued path (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `可写入工作区`, and `完全权限` in Chinese; `custom` is display state, never a target. +A pick submits the `/permission ` command line. The argued path (`/permission ` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `工作区内修改`, and `完全权限` in Chinese; `custom` is display state, never a target. ### The Settings row diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index bd5588b13c..a20d237f2a 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 选择器 -选中即提交 `/permission ` 命令行。带参路径(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「可写入工作区」和「完全权限」;`custom` 只是显示状态,绝非目标。 +选中即提交 `/permission ` 命令行。带参路径(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「工作区内修改」和「完全权限」;`custom` 只是显示状态,绝非目标。 ### 设置行 diff --git a/packages/client/ui-permission-presets/src/client/locales.ts b/packages/client/ui-permission-presets/src/client/locales.ts index 8a1ec2049c..43cd7695b6 100644 --- a/packages/client/ui-permission-presets/src/client/locales.ts +++ b/packages/client/ui-permission-presets/src/client/locales.ts @@ -7,7 +7,7 @@ export const zh = { 'loading': '加载中', 'unavailable': '不可用', 'preset.readOnly': '仅可查看', - 'preset.workspaceWrite': '可写入工作区', + 'preset.workspaceWrite': '工作区内修改', 'preset.fullAccess': '完全权限', 'confirm.title': '确认启用完全权限?', 'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。', @@ -38,7 +38,7 @@ export const en = { /** Simplified Chinese dictionary for the current-session popup gate. */ export const accessZh = { 'preset.readOnly': '仅可查看', - 'preset.workspaceWrite': '可写入工作区', + 'preset.workspaceWrite': '工作区内修改', 'preset.fullAccess': '完全权限', 'confirm.title': '确认启用完全权限?', 'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index 4ad456b47f..e6f9cd39f8 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -128,7 +128,7 @@ describe('ui-permission browser plugin', () => { }) b.locale.setLocale('zh') const localized = await c.ui.options(proj, new AbortController().signal) - expect(localized.map(option => option.label)).toEqual(['仅可查看', '可写入工作区', '完全权限']) + expect(localized.map(option => option.label)).toEqual(['仅可查看', '工作区内修改', '完全权限']) expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ title: '确认启用完全权限?', description: accessZh['confirm.description'], diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 13693a4e4d..b083a4074a 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -94,8 +94,8 @@ describe('PermissionRow', () => { fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' })) expect(mutate).not.toHaveBeenCalled() fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' })) - await screen.findByRole('button', { name: '可写入工作区' }) + fireEvent.click(screen.getByRole('menuitem', { name: '工作区内修改' })) + await screen.findByRole('button', { name: '工作区内修改' }) expect(mutate).toHaveBeenCalledOnce() }) @@ -166,7 +166,7 @@ describe('PermissionRow', () => { describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })) const button = await screen.findByRole('button', { name: '仅可查看' }) fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' })) + fireEvent.click(screen.getByRole('menuitem', { name: '工作区内修改' })) expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere') }) }) From d8e2ac5052b28f065eab4cefcf51482427d17102 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 31 Aug 2026 18:11:32 +0800 Subject: [PATCH 19/43] fix(web): retain completed streaming fence lines --- .../tests/streaming-fence-highlight.e2e.ts | 69 +++++++++++++++---- .../ui-primitives/src/markdown/CodeBlock.tsx | 2 +- .../streaming-code-block.client.spec.tsx | 30 ++++++++ 3 files changed, 85 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts index 4e74eb8764..6d65cdca0d 100644 --- a/apps/web/tests/streaming-fence-highlight.e2e.ts +++ b/apps/web/tests/streaming-fence-highlight.e2e.ts @@ -24,28 +24,48 @@ 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 FIRST_REPLY = '```ts\nconst first: number = 1\n' +const OPEN_REPLY = `${FIRST_REPLY}const second = "two"\nlet tail` const REPLY = `${OPEN_REPLY}\n\`\`\`` -/** Deterministic model response held after the visible fence body arrives. */ +/** Deterministic model response held after each visible fence-growth frame. */ 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 }) + private resolveFirstPaused!: () => void + private resolveFirstContinuation!: () => void + private resolveSecondPaused!: () => void + private resolveSecondContinuation!: () => void + private firstContinued = false + private secondContinued = false + readonly firstPaused = new Promise((resolve) => { this.resolveFirstPaused = resolve }) + readonly secondPaused = new Promise((resolve) => { this.resolveSecondPaused = resolve }) + private readonly firstContinuation = new Promise((resolve) => { this.resolveFirstContinuation = resolve }) + private readonly secondContinuation = new Promise((resolve) => { this.resolveSecondContinuation = resolve }) + + grow(): void { + if (this.firstContinued) return + this.firstContinued = true + this.resolveFirstContinuation() + } + + finish(): void { + if (this.secondContinued) return + this.secondContinued = true + this.resolveSecondContinuation() + } continue(): void { - if (this.continued) return - this.continued = true - this.resolveContinuation() + this.grow() + this.finish() } 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 + yield { type: 'text-delta', index: 0, text: FIRST_REPLY } + this.resolveFirstPaused() + await this.firstContinuation + yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) } + this.resolveSecondPaused() + await this.secondContinuation 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 } } @@ -115,12 +135,26 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', const settled = scaffold.whenTurnSettled(30_000) await writeComposerDraft(page, input, PROMPT) await input.press('Enter') - await adapter.paused + await adapter.firstPaused 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 }) + await block.evaluate((element) => { + element.setAttribute('data-stream-block-retained', 'true') + element.querySelector('pre.shiki')?.setAttribute('data-stream-pre-retained', 'true') + element.querySelector('pre.shiki .line')?.setAttribute('data-stream-line-retained', 'true') + }) + + adapter.grow() + await adapter.secondPaused + await expect.poll(() => block.locator('pre.shiki .line').count()).toBe(3) + expect(await block.evaluate(element => ({ + block: element.getAttribute('data-stream-block-retained'), + pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'), + line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'), + }))).toEqual({ block: 'true', pre: 'true', line: 'true' }) const midTree = await fenceTree(block) expect(midTree.language).toBe('ts') expect(midTree.lines).toHaveLength(3) @@ -133,12 +167,17 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', MODE, ) - adapter.continue() + adapter.finish() 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(await settledBlock.evaluate(element => ({ + block: element.getAttribute('data-stream-block-retained'), + pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'), + line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'), + }))).toEqual({ block: 'true', pre: 'true', line: 'true' }) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md']) diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 04bc7c2062..2a42092020 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -117,7 +117,7 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL pending = [] } const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index)) - const tailGroup = {pending}{tail} + const tailGroup = {[...pending, ...tail]} const body =
{groups}{tailGroup}
lineCacheRef.current = { code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, diff --git a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx index 3fe0847693..25cd22dcb9 100644 --- a/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx @@ -80,6 +80,26 @@ describe('StreamingHighlightSession', () => { expect(session.updateFrame('const a = 1\nlet b = 2\n// tail', 'ts')).toBe(second) }) + it('emits one completed line per frame across an 800-line stream', () => { + const session = new StreamingHighlightSession() + let code = '' + let generation: number | undefined + let appended = 0 + for (let index = 0; index < 800; index += 1) { + const line = `const value${String(index)} = ${String(index)}` + code += `${line}\n` + const frame = session.updateFrame(code, 'ts') + expect(frame?.appended).toHaveLength(1) + expect(frame?.appended[0]?.map(span => span.text).join('')).toBe(line) + generation ??= frame?.generation + expect(frame?.generation).toBe(generation) + appended += frame?.appended.length ?? 0 + } + // Frame cardinality is stable across CI hosts; wall-clock thresholds are + // diagnostics owned by the manual Web performance inventory. + expect(appended).toBe(800) + }) + it('is idempotent per input: repeated calls return the identical result array', () => { const session = new StreamingHighlightSession() const result = session.update('const a = 1', 'ts') @@ -224,6 +244,16 @@ describe('CodeBlock streaming arm', () => { expect(view.container.querySelector('pre.shiki')?.textContent).toBe('const a = 1\nlet partial = 2\n// tail') }) + it('keeps a tail line mounted when the next frame completes it', () => { + const view = render() + const firstLine = view.container.querySelector('pre.shiki .line') + expect(firstLine).not.toBeNull() + view.rerender( + , + ) + expect(view.container.querySelector('pre.shiki .line')).toBe(firstLine) + }) + it('keeps completed line groups mounted while later groups grow', () => { const code = (count: number) => Array.from({ length: count }, (_, index) => `const v${String(index)} = ${String(index)}`).join('\n') const view = render() From faa977fb2960521c30e80612442796b7677aadd2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 31 Aug 2026 18:59:28 +0800 Subject: [PATCH 20/43] fix(web): align Schedule catalog within viewport --- .../2026-08-05-durable-web-schedule.i18n.yaml | 4 +- .../2026-08-05-durable-web-schedule.md | 4 +- .../2026-08-05-durable-web-schedule.zh.md | 4 +- apps/web/tests/schedule-after.e2e.ts | 44 +++++++++++++++---- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 4 +- packages/client/ui-schedule/package.json | 1 + .../client/ScheduleCatalogAction.module.css | 4 +- .../src/client/ScheduleCatalogAction.tsx | 29 +++++++++--- .../schedule-catalog-action.client.spec.tsx | 35 ++++++++++++++- pnpm-lock.yaml | 3 ++ 12 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index b959794f2d..5381b7f346 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.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-durable-web-schedule.md -2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc -2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251 +2026-08-05-durable-web-schedule.md: bac4a5cfd8965032dad2cf689ca42b1f8e5da1e3 +2026-08-05-durable-web-schedule.zh.md: 266a90d3a56caccda56be9fe648c059939b8155f diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index 2a07d8257d..bac4a5cfd8 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -60,7 +60,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing ### Read-only Web catalog -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. +The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction. ## Alternatives considered @@ -80,7 +80,7 @@ The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](. ## Verification -Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one Schedule-catalog smoke for overlay reachability, the current header catalog, ordinary/search alarms, narrow dark layout, and one live empty update. +Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index c3a37f69ab..266a90d3a5 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -60,7 +60,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒 ### 只读 Web 目录 -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。 +Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。 ## 已考虑的替代方案 @@ -80,7 +80,7 @@ Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../pa ## 验证 -包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 Schedule 目录 smoke 覆盖 overlay 可达性、当前 header 目录、普通/搜索闹钟、窄屏暗色布局与一次 live empty 更新。 +包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。 ## 后果 diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 7dfe576560..d0e09bfd14 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,6 +1,6 @@ /** Keyless assembled-Web evidence for conversational Schedule delivery. */ -import { readFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -29,6 +29,7 @@ import { type WebScaffold, } from './scaffold.ts' import { + REPO_ROOT, connectFreshWorkspace, conversationContextKey, saveFailureShot, @@ -708,18 +709,34 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const catalog = page.getByRole('list', { name: 'Active reminders' }) await catalog.waitFor({ timeout: 10_000 }) expect(await catalog.getByRole('listitem').count()).toBe(3) - const lightLayout = await catalog.evaluate((element) => { - const box = element.getBoundingClientRect() + const lightLayout = await page.evaluate(() => { + const triggerElement = document.querySelector('button[aria-label="3 reminders"]') + const catalogElement = document.querySelector('[aria-label="Active reminders"]') + if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) { + throw new Error('active reminder trigger or catalog is not mounted') + } + const triggerBox = triggerElement.getBoundingClientRect() + const catalogBox = catalogElement.getBoundingClientRect() + const viewport = window.innerWidth return { - width: box.width, - right: box.right, - viewport: window.innerWidth, + bodyPortal: catalogElement.parentElement === document.body, + position: getComputedStyle(catalogElement).position, + triggerLeft: triggerBox.left, + catalogLeft: catalogBox.left, + catalogRight: catalogBox.right, + width: catalogBox.width, + viewport, + expectedLeft: Math.min(Math.max(16, triggerBox.left), viewport - catalogBox.width - 16), scrollWidth: document.documentElement.scrollWidth, - background: getComputedStyle(element).backgroundColor, + background: getComputedStyle(catalogElement).backgroundColor, } }) + expect(lightLayout.bodyPortal).toBe(true) + expect(lightLayout.position).toBe('fixed') expect(lightLayout.width).toBe(336) - expect(lightLayout.right).toBeLessThanOrEqual(lightLayout.viewport) + expect(lightLayout.catalogLeft).toBe(lightLayout.expectedLeft) + expect(lightLayout.catalogLeft).toBeLessThan(lightLayout.triggerLeft) + expect(lightLayout.catalogRight).toBeLessThanOrEqual(lightLayout.viewport - 16) expect(lightLayout.scrollWidth).toBeLessThanOrEqual(lightLayout.viewport) expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) @@ -760,6 +777,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { })) expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) + const evidenceDir = join(REPO_ROOT, '.artifacts') + await mkdir(evidenceDir, { recursive: true }) + await writeFile( + join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.json'), + `${JSON.stringify(lightLayout, null, 2)}\n`, + ) + await page.screenshot({ + path: join(evidenceDir, 'web-e2e-schedule-catalog-left-alignment.png'), + fullPage: true, + }) + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkBackground = await catalog.evaluate(element => getComputedStyle(element).backgroundColor) expect(darkBackground).not.toBe('rgba(0, 0, 0, 0)') diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 14a1f747f0..60cf025dd8 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/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-schedule/README.md -README.md: 2d75746876daf453b01ff2e84b6e62ed018c205c -README.zh.md: 32925e73aed252189764c4eafc0f5ecc58bb2a08 +README.md: 2792cef4ae5cc0d0ad76262d05e06c83a7d0fc54 +README.zh.md: 0add76f0455ff2b2ca720444f3a0250134664853 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 2d75746876..2792cef4ae 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -35,7 +35,7 @@ The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` thr ### Read and dismiss the catalog -Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The 336px popover scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. +Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The body-portaled popover targets 336px, shares the trigger's left edge when space permits, and shifts left to retain a 16px viewport margin when the trigger is near the right edge; its maximum width is the viewport width minus 32px. It scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. Only the native trigger button enters the tab order. Enter and Space use normal button activation; while focus remains on the trigger or catalog, Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists. @@ -47,7 +47,7 @@ Only the native trigger button enters the tab order. Enter and Space use normal
Implementation internals — click to expand -The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged. +The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. The component portals the catalog to `document.body` and gives its trigger and panel refs to `useAnchoredPosition`, which publishes fixed coordinates after measuring the rendered panel, keeps a 5px gap below the trigger, clamps to a 16px viewport margin, and remeasures on resize, captured scroll, and panel resize. The catalog ref also makes pointer presses inside the portal part of the existing dismissal boundary. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged. ### Source map diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 32925e73ae..0add76f045 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -35,7 +35,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml ### 阅读和关闭目录 -每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。336px 宽的弹层在需要时纵向滚动,不显示 Schedule id、原始 UTC 值、详情或操作控件。 +每一行显示可完整换行的 prompt、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。通过 portal 挂到 body 的弹层目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,最大宽度为视口宽度减 32px。弹层会在需要时纵向滚动,且不显示 Schedule id、原始 UTC 值、详情或操作控件。 只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;焦点仍在触发器或目录内时,Escape 会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。Session 打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。 @@ -47,7 +47,7 @@ dsh web --patch apps/cli/config/examples/schedule/cordis.yml
实现细节——点击展开 -浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。 +浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 Agent 与 Subagent 上下文之后、后台 Jobs 之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。组件把目录 portal 到 `document.body`,并将触发器与面板 ref 交给 `useAnchoredPosition`;该 hook 在测量已渲染面板后发布 fixed 坐标,使面板位于触发器下方 5px、钳制在 16px 视口边距内,并在 resize、捕获阶段 scroll 与面板 resize 时重新测量。目录 ref 也让 portal 内的指针按下继续属于既有 dismissal 边界之内。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。 ### 源码地图 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 57cd158c11..9cae9286e8 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index 3f4d6c5380..82c10bcfb7 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -39,9 +39,7 @@ } .menu { - position: absolute; - top: calc(100% + 5px); - right: 0; + position: fixed; z-index: 100; box-sizing: border-box; display: flex; diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index 34d071f9bc..64b57d7e2a 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -1,8 +1,12 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { + useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, +} from 'react' +import { createPortal } from 'react-dom' import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import { IconAlarmClockOutline16, IconChevronDownOutline14, + useAnchoredPosition, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -19,6 +23,7 @@ type TimeUnit = 'day' | 'hour' | 'minute' | 'second' const EMPTY_RECORDS: readonly ScheduleRecord[] = [] const SECOND_MS = 1_000 const SECOND_UNIT = { unit: 'second', seconds: 1 } as const +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } const UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [ { unit: 'day', seconds: 86_400 }, { unit: 'hour', seconds: 3_600 }, @@ -105,8 +110,17 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(null) + const catalogRef = useRef(null) + const catalogPosition = useAnchoredPosition({ + open, + anchorRef: triggerRef, + panelRef: catalogRef, + side: 'bottom', + gap: 5, + margin: 16, + }) - useDismissOnOutsidePointer(rootRef, open, setOpen) + useDismissOnOutsidePointer(rootRef, open, setOpen, catalogRef) useEffect(() => { if (!open) return @@ -151,8 +165,13 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule ) const catalog = open - ? ( -
    + ? createPortal(( +
      {rows.map((record) => { const overdue = Date.parse(record.scheduledAt) <= now return ( @@ -178,7 +197,7 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule ) })}
    - ) + ), document.body) : null return ( diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index c181a5f745..086d9e05b9 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -27,6 +27,7 @@ beforeEach(() => { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.useRealTimers() }) @@ -124,6 +125,34 @@ describe('ScheduleCatalogAction visibility', () => { }) }) +describe('ScheduleCatalogAction positioning', () => { + it('portals the catalog to the body and left-aligns it when space is available', () => { + const active = [record('active', 'after', START + 60_000)] + const view = render() + const trigger = screen.getByRole('button', { name: '1 reminder' }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + x: 240, + y: 20, + left: 240, + right: 320, + top: 20, + bottom: 48, + width: 80, + height: 28, + toJSON: () => ({}), + }) + + fireEvent.click(trigger) + + const catalog = screen.getByRole('list', { name: en['list.aria'] }) + expect(view.container.contains(catalog)).toBe(false) + expect(catalog.parentElement).toBe(document.body) + expect(catalog.style.left).toBe('240px') + expect(catalog.style.top).toBe('53px') + expect(catalog.style.visibility).toBe('') + }) +}) + describe('ScheduleCatalogAction rows', () => { it('shows only prompt and the three derived metadata fields, with overdue records first', () => { const rawPrompt = ' Keep the complete long reminder prompt visible without truncation.' @@ -238,11 +267,13 @@ describe('ScheduleCatalogAction dismissal', () => { expect(document.activeElement).toBe(sibling) }) - it('toggles from the trigger and dismisses only on an outside pointer press', () => { + it('keeps a pointer press inside the portaled catalog open and dismisses outside', () => { render() const trigger = screen.getByRole('button') fireEvent.click(trigger) - fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] })) + const catalog = screen.getByRole('list', { name: en['list.aria'] }) + expect(catalog.parentElement).toBe(document.body) + fireEvent.pointerDown(catalog) expect(trigger.getAttribute('aria-expanded')).toBe('true') fireEvent.pointerDown(document.body) expect(trigger.getAttribute('aria-expanded')).toBe('false') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 272909451f..45098dc345 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3080,6 +3080,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1 From 7e2eacb1fe6496b1b4cd5a3d68d29e07dba89fc8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 12:50:56 +0800 Subject: [PATCH 21/43] feat(session-turn-outline): whole-log turn outline projection New turnOutline projection unit serving every started turn's number, turn/start seq, and bounded first-prompt preview through the session-projection seam, mounted in the web-app bundle for the chat turn rail. Entries stay strictly increasing; previews mirror the rail's loaded-turn preview budget. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 6 + docs/module-graph.zh.md | 6 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/session/README.i18n.yaml | 4 +- packages/session/README.md | 1 + packages/session/README.zh.md | 1 + .../session-turn-outline/README.i18n.yaml | 6 + .../session/session-turn-outline/README.md | 124 +++++++++++++ .../session/session-turn-outline/README.zh.md | 124 +++++++++++++ .../session/session-turn-outline/package.json | 62 +++++++ .../session-turn-outline/src/client.ts | 10 ++ .../session/session-turn-outline/src/index.ts | 29 +++ .../session-turn-outline/src/invariant.ts | 35 ++++ .../session-turn-outline/src/projection.ts | 91 ++++++++++ .../session/session-turn-outline/src/types.ts | 37 ++++ .../tests/loader-composition.spec.ts | 88 ++++++++++ .../tests/projection.spec.ts | 165 ++++++++++++++++++ .../session-turn-outline/tsconfig.json | 30 ++++ pnpm-lock.yaml | 31 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 4 + tsconfig.host.json | 1 + 27 files changed, 866 insertions(+), 6 deletions(-) create mode 100644 packages/session/session-turn-outline/README.i18n.yaml create mode 100644 packages/session/session-turn-outline/README.md create mode 100644 packages/session/session-turn-outline/README.zh.md create mode 100644 packages/session/session-turn-outline/package.json create mode 100644 packages/session/session-turn-outline/src/client.ts create mode 100644 packages/session/session-turn-outline/src/index.ts create mode 100644 packages/session/session-turn-outline/src/invariant.ts create mode 100644 packages/session/session-turn-outline/src/projection.ts create mode 100644 packages/session/session-turn-outline/src/types.ts create mode 100644 packages/session/session-turn-outline/tests/loader-composition.spec.ts create mode 100644 packages/session/session-turn-outline/tests/projection.spec.ts create mode 100644 packages/session/session-turn-outline/tsconfig.json diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 49cb7bb2c7..8a605e9add 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: ec077edd10962f324db242698b1d652563c3ac2f -config-catalog.zh.md: d349575cb2884bd2e80097c8345db8d0227107c7 +config-catalog.md: 5d19ab42f0423cd92fd6227bf1c169db1269d9d4 +config-catalog.zh.md: 77fdabb9da9b78aba6c9704fd0ec5c095ea14679 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ec077edd10..5d19ab42f0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3353,6 +3353,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) +- `@deepseek-ai/dsh-session-turn-outline` — requires `sessionProjections` ([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d349575cb2..77fdabb9da 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3355,6 +3355,7 @@ export interface Config { - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) +- `@deepseek-ai/dsh-session-turn-outline` — 需要 `sessionProjections`([`packages/session/session-turn-outline/src/index.ts`](../packages/session/session-turn-outline/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cbec50a70c..6486a6d319 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: 2229efcd7e76b3b224eb307ee7de9ecea0ad85d7 -module-graph.zh.md: 3edca4ea261f68593973149b21e72e4a25d7a504 +module-graph.md: 1fa0aec5cf88f8deaa4e3bfd6687c7dc8c06f669 +module-graph.zh.md: a9da000b8966fa8daaca13c95af097e4b342d633 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2229efcd7e..1fa0aec5cf 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -296,6 +296,7 @@ flowchart TD pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"] pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"] pkg_session_title_llm["session-title-llm"] + pkg_session_turn_outline["session-turn-outline"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -530,6 +531,10 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_session_turn_outline --> pkg_invariants + pkg_session_turn_outline --> pkg_llm + pkg_session_turn_outline --> pkg_session + pkg_session_turn_outline --> pkg_session_projection pkg_settings_file --> pkg_atomic_write pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants @@ -1431,6 +1436,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3edca4ea26..a9da000b89 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -298,6 +298,7 @@ flowchart TD pkg_session_title_all_prompts_llm["session-title-all-prompts-llm"] pkg_session_title_first_prompt_llm["session-title-first-prompt-llm"] pkg_session_title_llm["session-title-llm"] + pkg_session_turn_outline["session-turn-outline"] end subgraph group_settings["packages/settings"] pkg_settings["settings"] @@ -532,6 +533,10 @@ flowchart TD pkg_session_stats --> pkg_llm pkg_session_stats --> pkg_session pkg_session_stats --> pkg_session_projection + pkg_session_turn_outline --> pkg_invariants + pkg_session_turn_outline --> pkg_llm + pkg_session_turn_outline --> pkg_session + pkg_session_turn_outline --> pkg_session_projection pkg_settings_file --> pkg_atomic_write pkg_settings_file --> pkg_home_paths pkg_settings_file --> pkg_invariants @@ -1433,6 +1438,7 @@ flowchart TD | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`session-turn-outline`](../packages/session/session-turn-outline) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 15a5df12cc..79c62a6e20 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -72,6 +72,11 @@ - id: session-stats name: '@deepseek-ai/dsh-session-stats' + # Whole-log turn outline for the chat turn rail (the turnOutline + # projection key): every turn stays navigable before its events page in. + - id: session-turn-outline + name: '@deepseek-ai/dsh-session-turn-outline' + # Resolve bind host, SSH launch, and display once at boot, then mount the # matching dual-face directory picker. Mount -native or -browse directly in # an overlay to pin the interaction. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 2643f13062..0ba57ce2b9 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -107,6 +107,7 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-session-turn-outline": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml index 6a8abb968a..ec8aaf2ef0 100644 --- a/packages/session/README.i18n.yaml +++ b/packages/session/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/README.md -README.md: 63cc118decffaec1073c75d9d8c5967f866016fa -README.zh.md: f24883518d779dd4cd069e828d4d6ba01d8caa07 +README.md: b47bd7e6384919af4add2746417b232f6873aaf0 +README.zh.md: fa5bc08fc04d32de15f9fe357e7b18b23bb084d3 diff --git a/packages/session/README.md b/packages/session/README.md index 63cc118dec..b47bd7e638 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -40,6 +40,7 @@ The group splits into four families: durable storage (persistence seam, backends | [`session-projection/`](session-projection/README.md) | Defines and drives projection units that fold committed events into whole current values | `ctx.sessionProjections` | | [`session-projection-cache/`](session-projection-cache/README.md) | Persists projection checkpoints so cold reads skip full log loads | `ctx.sessionProjectionCache` | | [`session-stats/`](session-stats/README.md) | Serves whole-log conversation counts and wall times through the `sessionStats` unit | registers on `ctx.sessionProjections` | +| [`session-turn-outline/`](session-turn-outline/README.md) | Serves the whole-log turn outline (turn, `turn/start` seq, prompt preview) through the `turnOutline` unit | registers on `ctx.sessionProjections` | ### Titles diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md index f24883518d..fa5bc08fc0 100644 --- a/packages/session/README.zh.md +++ b/packages/session/README.zh.md @@ -40,6 +40,7 @@ session 组让 agent(智能体)的对话在实时 loop 之外持久可复用 | [`session-projection/`](session-projection/README.zh.md) | 定义并驱动把已提交事件折叠为完整当前值的投影单元 | `ctx.sessionProjections` | | [`session-projection-cache/`](session-projection-cache/README.zh.md) | 持久化投影检查点,使冷读跳过全量日志加载 | `ctx.sessionProjectionCache` | | [`session-stats/`](session-stats/README.zh.md) | 通过 `sessionStats` 单元提供全日志会话计数与墙钟时间 | 注册到 `ctx.sessionProjections` | +| [`session-turn-outline/`](session-turn-outline/README.zh.md) | 通过 `turnOutline` 单元提供全日志轮次大纲(轮次号、`turn/start` seq、提示词预览) | 注册到 `ctx.sessionProjections` | ### 标题 diff --git a/packages/session/session-turn-outline/README.i18n.yaml b/packages/session/session-turn-outline/README.i18n.yaml new file mode 100644 index 0000000000..c27b36d44a --- /dev/null +++ b/packages/session/session-turn-outline/README.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 packages/session/session-turn-outline/README.md +README.md: f56445df70fcfd637657c4f7640dd5c415af7063 +README.zh.md: f10a0a61d9df6534d8e2f9566814c6112e2e678e diff --git a/packages/session/session-turn-outline/README.md b/packages/session/session-turn-outline/README.md new file mode 100644 index 0000000000..f56445df70 --- /dev/null +++ b/packages/session/session-turn-outline/README.md @@ -0,0 +1,124 @@ +--- +description: "Whole-log turn outline for clients and maintainers composing or debugging the turnOutline projection unit behind full-session turn navigation." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-session-turn-outline + +English | [中文](README.zh.md) + +## Summary + +`dsh-session-turn-outline` serves the whole-log turn outline — every started turn with its `turn/start` seq and a bounded first-prompt preview — as the `turnOutline` projection unit. A client that pages history in windows reads the outline to offer every turn of the session (loaded or not) and to target its backwards paging at the exact seq that brings a turn's events in. Choose it in compositions that already mount the projection registry, such as the web app bundle whose chat turn rail is the reference consumer; assemblies without the registry are unaffected and their consumers fall back to loaded-window navigation. Setup and entry semantics come first; the fold internals live in a collapsible developer section below. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the plugin beside the session store and the projection registry when clients should navigate every turn of a session without holding its complete event log. The unit registers only when the registry is present. + +### Composition + +```yaml +- name: '@deepseek-ai/dsh-session' +- name: '@deepseek-ai/dsh-session-projection' +- name: '@deepseek-ai/dsh-session-turn-outline' +``` + +### What an entry means + +| Field | Meaning | +|---|---| +| `turn` | Host-assigned turn number from the `turn/start` payload | +| `seq` | The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn | +| `prompt` | Preview of the turn's first human prompt (space-joined text blocks, collapsed whitespace, 160-character cap); `''` until an eligible prompt lands | + +Entries are strictly increasing by `turn`, and the wire value is the complete outline (whole-value rule): consumers replace, never merge. Only `user/message` events with the human `user` source fill previews, so injected context and tool results never leak into navigation; a turn whose prompt is images-only keeps `''` and consumers label it by number. The preview budget matches the chat rail's loaded-turn preview, so a turn shows the same words before and after its events load. + +### Failures and recovery + +The unit is inert without the projection registry: `inject` keeps the fiber pending and nothing registers, so other assemblies lack the `turnOutline` key. Unmounting the plugin removes the key, because registrations are effects on the mounting fiber. Persisted-cache rows are schema-validated on restore — including the strictly-increasing turn order — so a corrupt row is discarded instead of seeding a broken fold. + +----- + + +## Understand the implementation + +
    +Implementation internals — click to expand + +This section explains the fold behind the outline; the observable behavior is fully covered in [Use this package](#use-this-package). + +### Design concept + +The unit is a pure fold over committed session events. `turn/start` — not the prompt `user/message` — anchors each entry because its seq is the load-through target for a jump: the agent loop logs `turn/start` before the turn's prompt and steps, so a window paged back through that seq contains the whole turn. The preview then fills from the first human `user/message`, and only while the newest entry is still empty — later human messages in the same turn (steering) keep the first preview. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: `inject`, unit registration on the mounting fiber | +| [`src/projection.ts`](src/projection.ts) | The fold: entry append, preview fill, wire view | +| [`src/types.ts`](src/types.ts) | One home of the `turnOutline` projection-key declaration and entry types | + +### Fold rules + +- Uninteresting events return the same state reference; the registry's `Object.is` gate keeps the change feed quiet — the outline moves at most twice per turn. +- A `turn/start` that does not advance the turn number is skipped, keeping the outline sorted; a retried boundary's prompt then lands on the standing entry. +- State and wire view are the same value, so the persisted-cache state schema is the wire schema. + +
    + +----- + + +## Further Exploration + +Read these pages when the unit's contract is not enough. They move from the registry that drives units to adjacent session packages. + +- [Session projection subsystem](../../../docs/subsystems/session-projection.md) — the registry that drives units and serves snapshot and change-feed values. +- [Session projection registry package](../session-projection/README.md) — the registry contract units register against. +- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages. + +----- + + +## Model Experience + +None, as the turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing. + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + + + + +These limits define what the outline describes and when the unit is absent. They are current package constraints. + +- **The wire value grows with the session** — every change pushes the complete outline (whole-value rule), roughly 200 bytes per turn; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it. +- **Previews carry the prompt only** — assistant-response previews stay window-scoped in the consumer; the outline never re-reads message bodies. +- **A turn without an eligible text prompt keeps `''`** — images-only and command-only turns are navigable but labeled by number. +- **Mounted only where the projection registry is composed** — other assemblies serve no `turnOutline` key, and their consumers fall back to loaded-window navigation. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    diff --git a/packages/session/session-turn-outline/README.zh.md b/packages/session/session-turn-outline/README.zh.md new file mode 100644 index 0000000000..f10a0a61d9 --- /dev/null +++ b/packages/session/session-turn-outline/README.zh.md @@ -0,0 +1,124 @@ +--- +description: "面向组合或调试 turnOutline 投影单元的客户端与维护者的全量轮次大纲说明,支撑整会话轮次导航。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-session-turn-outline + +[English](README.md) | 中文 + +## 概述 + +`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 与有界的首条提示词预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +当客户端需要在不持有完整事件日志的情况下导航会话的每一轮时,在会话存储与投影注册表旁挂载此插件。只有存在注册表时单元才会注册。 + +### 组合 + +```yaml +- name: '@deepseek-ai/dsh-session' +- name: '@deepseek-ai/dsh-session-projection' +- name: '@deepseek-ai/dsh-session-turn-outline' +``` + +### 各字段含义 + +| 字段 | 含义 | +|---|---| +| `turn` | `turn/start` 载荷里的宿主分配轮次号 | +| `seq` | 该轮 `turn/start` 事件的 seq——窗口向后分页越过此 seq 即载入整轮 | +| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、160 字符封顶);合格提示词落日志前为 `''` | + +条目按 `turn` 严格递增,wire 值是完整大纲(整值规则):消费者整体替换,从不合并。只有带人类 `user` 来源的 `user/message` 事件才会填充预览,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。 + +### 失败与恢复 + +没有投影注册表时单元是惰性的:`inject` 使 fiber 保持挂起,不注册任何内容,因此其他装配缺少 `turnOutline` 键。卸载插件会移除该键,因为注册是挂载 fiber 上的 effect。持久缓存行在恢复时经受 schema 校验——包括轮次严格递增的顺序——损坏的行被丢弃而不会喂坏折叠。 + +----- + + +## 理解实现 + +
    +实现细节——点击展开 + +本节解释大纲背后的折叠;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计理念 + +该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。预览随后由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:`inject`、在挂载 fiber 上注册单元 | +| [`src/projection.ts`](src/projection.ts) | 折叠:条目追加、预览填充、wire 视图 | +| [`src/types.ts`](src/types.ts) | `turnOutline` 投影键声明与条目类型的唯一归属 | + +### 折叠规则 + +- 不相关事件返回同一状态引用;注册表的 `Object.is` 门禁保持变更流安静——大纲每轮至多变动两次。 +- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的提示词随后落在既有条目上。 +- 状态与 wire 视图是同一个值,因此持久缓存的状态 schema 就是 wire schema。 + +
    + +----- + + +## 进一步探索 + +当单元约定不够用时阅读以下页面。它们从驱动单元的注册表逐步进入相邻的会话包。 + +- [会话投影子系统](../../../docs/subsystems/session-projection.zh.md)——驱动单元并提供快照与变更流值的注册表。 +- [会话投影注册表包](../session-projection/README.zh.md)——单元注册所依据的注册表约定。 +- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。 + +----- + + +## 模型体验 + +无,因为 turnOutline 单元把已写入日志的轮次边界折叠成面向客户端的读模型,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无;本包从不组装或发送提供方请求。 + +## 已知限制与延期工作 + + + + +这些限制说明大纲描述什么、单元何时缺失。它们是当前包约束。 + +- **wire 值随会话增长**——每次变更推送完整大纲(整值规则),约每轮 200 字节;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。 +- **预览只含提示词**——助手回复预览仍由消费者按窗口提供;大纲从不回读消息正文。 +- **没有合格文本提示词的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注。 +- **仅在组合了投影注册表时挂载**——其他装配不提供 `turnOutline` 键,其消费者回退到仅按已加载窗口导航。 + + +### 开发备注 + +
    +维护者的工作上下文——点击展开 + +无。 + +
    diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json new file mode 100644 index 0000000000..7c12ba5162 --- /dev/null +++ b/packages/session/session-turn-outline/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-session-turn-outline", + "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", + "version": "0.1.2-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-turn-outline" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^" + } +} diff --git a/packages/session/session-turn-outline/src/client.ts b/packages/session/session-turn-outline/src/client.ts new file mode 100644 index 0000000000..9d15affea0 --- /dev/null +++ b/packages/session/session-turn-outline/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the turn-outline domain: a pure re-export + * of the package's types outlet. Client code imports ONLY the client + * namespace (repo discipline), so `./client` projects the same single-source + * content `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-session-turn-outline/client + */ + +export type * from './types.ts' diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts new file mode 100644 index 0000000000..0105e7c186 --- /dev/null +++ b/packages/session/session-turn-outline/src/index.ts @@ -0,0 +1,29 @@ +/** + * Function plugin registering the `turnOutline` projection unit: the + * whole-log turn outline (turn number, `turn/start` seq, bounded prompt + * preview) served through the session-projection seam — registry snapshot, + * change feed, and every projection carrier — so a client can offer every + * turn of a session and target history paging at exact seqs without holding + * the events. The plugin owns only the fold; delivery is the seam's. + * + * @module @deepseek-ai/dsh-session-turn-outline + */ + +import type { Context } from '@deepseek-ai/cordis' +import { turnOutlineProjectionDefinition } from './projection.ts' + +export type * from './types.ts' + +/** Cordis plugin name. */ +export const name = 'session-turn-outline' +/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ +export const inject = ['sessionProjections'] + +/** + * Register the `turnOutline` unit; the registration is an effect on this + * plugin's fiber, so unloading removes the key. + * @param ctx - registrant context carrying the projection registry. + */ +export function apply(ctx: Context): void { + ctx.sessionProjections.register(turnOutlineProjectionDefinition) +} diff --git a/packages/session/session-turn-outline/src/invariant.ts b/packages/session/session-turn-outline/src/invariant.ts new file mode 100644 index 0000000000..82f1c94ccc --- /dev/null +++ b/packages/session/session-turn-outline/src/invariant.ts @@ -0,0 +1,35 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-turn-outline`. + * @module @deepseek-ai/dsh-session-turn-outline/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-turn-outline' + +/** Cordis companion plugin name. */ +export const name = 'session-turn-outline-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the package owns a single pure projection fold whose + * wire payload is schema-validated by the projection registry at every + * snapshot and change-feed emission (including the strictly-increasing turn + * order the fold maintains), and the event relations the fold relies on + * (host-assigned monotonic turn numbers on `turn/start`, the turn's prompt + * `user/message` logged after its boundary) are owned and runtime-checked by + * dsh-agent-loop and the session surface, not here. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts new file mode 100644 index 0000000000..53a405134e --- /dev/null +++ b/packages/session/session-turn-outline/src/projection.ts @@ -0,0 +1,91 @@ +/** + * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries + * and first human prompts into the whole-log turn outline the chat rail + * renders for turns outside a client's paged event window. + * + * `turn/start` — not the prompt `user/message` — anchors each entry because + * its seq is the load-through target for a jump: the loop logs `turn/start` + * before the turn's prompt and steps, so a window paged back through that seq + * contains the whole turn. The preview mirrors the rail's loaded-turn preview + * (space-joined text blocks, collapsed whitespace, 160-character cap) so a + * turn shows the same words before and after its events load. + * + * @module @deepseek-ai/dsh-session-turn-outline/projection + */ + +import { z } from 'zod' +import type { ZodType } from 'zod' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { TurnOutlineProjection } from './types.ts' + +/** Preview budget per entry, matching the rail's loaded-turn preview clamp. */ +const PREVIEW_LIMIT = 160 + +/** Space-join text blocks until the budget is met, then normalize and cap. */ +function promptPreview(content: SessionEvent<'user/message'>['data']['content']): string { + let text = '' + for (const block of content) { + if (block.type !== 'text') continue + text += text === '' ? block.text : ` ${block.text}` + if (text.length >= PREVIEW_LIMIT) break + } + return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT) +} + +const turnOutlineSchema: ZodType = z.object({ + turns: z.array(z.object({ + turn: z.number().int().nonnegative(), + seq: z.number().int().nonnegative(), + prompt: z.string().max(PREVIEW_LIMIT), + }).strict()), +}).strict().superRefine((state, context) => { + let previous = -1 + for (const entry of state.turns) { + if (entry.turn <= previous) { + context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' }) + return + } + previous = entry.turn + } +}) + +const EMPTY_OUTLINE: TurnOutlineProjection = { turns: [] } + +/** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */ +export const turnOutlineProjectionDefinition = { + key: 'turnOutline', + stateVersion: 1, + stateSchema: turnOutlineSchema, + init: () => EMPTY_OUTLINE, + apply: (state, event) => { + // Every uninteresting event returns the same reference (Object.is gates the change feed). + switch (event.type) { + case 'turn/start': { + const last = state.turns.at(-1) + // Order guard: a boundary that does not advance the turn number keeps + // the outline sorted, and a retried turn's prompt lands on the + // standing entry. + if (last !== undefined && event.data.turn <= last.turn) return state + return { turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '' }] } + } + case 'user/message': { + // Only the newest turn can still be waiting for its opening human + // prompt; later human messages in the same turn (steering) keep the + // first preview. + if (event.data.source.kind !== 'user') return state + const last = state.turns.at(-1) + if (last === undefined || last.prompt !== '') return state + const prompt = promptPreview(event.data.content) + if (prompt === '') return state + return { turns: [...state.turns.slice(0, -1), { ...last, prompt }] } + } + default: + return state + } + }, + wire: { + viewSchema: turnOutlineSchema, + view: state => state, + }, +} satisfies ProjectionDefinition<'turnOutline', TurnOutlineProjection> diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts new file mode 100644 index 0000000000..7746c58558 --- /dev/null +++ b/packages/session/session-turn-outline/src/types.ts @@ -0,0 +1,37 @@ +/** + * Pure types of the turn-outline domain: the ONE home of the `turnOutline` + * projection-key declaration, free of this package's host-side value imports + * (zod, the projection definition). Host consumers import `./types`; client + * aggregates import `./client`, which re-exports this module. + * + * @module @deepseek-ai/dsh-session-turn-outline/types + */ + +export {} + +/** One started turn's outline facts, independent of what a client has paged in. */ +export interface TurnOutlineEntry { + /** Host-assigned turn number (the `turn/start` payload). */ + readonly turn: number + /** The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn. */ + readonly seq: number + /** Bounded preview of the turn's first human prompt; `''` until an eligible prompt lands. */ + readonly prompt: string +} + +/** Whole-log turn outline: every started turn, strictly increasing by `turn`. */ +export interface TurnOutlineProjection { + /** Started turns in ascending turn order. */ + readonly turns: readonly TurnOutlineEntry[] +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Whole-log turn outline fold state (identical to the wire view). */ + turnOutline: TurnOutlineProjection + } + interface SessionProjectionMap { + /** Every started turn with its `turn/start` seq and bounded prompt preview; see {@link TurnOutlineProjection}. */ + turnOutline: TurnOutlineProjection + } +} diff --git a/packages/session/session-turn-outline/tests/loader-composition.spec.ts b/packages/session/session-turn-outline/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9568cb83c6 --- /dev/null +++ b/packages/session/session-turn-outline/tests/loader-composition.spec.ts @@ -0,0 +1,88 @@ +/** + * REAL-composition proof: the shipped YAML shape (session + projection + * registry + session-turn-outline) boots through the vendored Loader, the + * function plugin's namespace survives (no default export), and a logged turn + * with its prompt serves the outline through the composed registry. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +async function loadYaml(lines: readonly string[]): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-session-turn-outline-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [...lines, ''].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], + ['@deepseek-ai/dsh-session-turn-outline', SessionTurnOutlinePlugin], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +describe('real Loader composition', () => { + it('loads the shipped session-turn-outline YAML shape and serves the outline', async () => { + const loaded = await loadYaml([ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-projection'", + "- name: '@deepseek-ai/dsh-session-turn-outline'", + ]) + + const unloaded = [...loaded.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + + const session = loaded.sessions.create(SessionId('composed')) + const boundary = session.append('turn/start', { turn: 1 }).seq + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'composed prompt' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(loaded.sessionProjections.snapshot(session).values.turnOutline) + .toEqual({ turns: [{ turn: 1, seq: boundary, prompt: 'composed prompt' }] }) + }) + + it('keeps the function-plugin namespace free of a default export', () => { + // A default export beside the named form makes the Loader discard the + // namespace (postmortem 0001) — pin its absence. + expect('default' in SessionTurnOutlinePlugin).toBe(false) + }) +}) diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts new file mode 100644 index 0000000000..80703ce778 --- /dev/null +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -0,0 +1,165 @@ +/** + * The `turnOutline` projection unit: mounting the plugin beside the + * projection registry serves the whole-log turn outline (turn number, + * `turn/start` seq, bounded first-prompt preview); compositions without the + * registry are unaffected; unmounting the plugin removes the key (HMR + * safety). Narrow fold paths with fabricated envelopes (non-human sources, + * regressive turn numbers) run against the exported definition directly. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' +import { turnOutlineProjectionDefinition } from '@deepseek-ai/dsh-session-turn-outline/src/projection.ts' +import type { TurnOutlineProjection } from '@deepseek-ai/dsh-session-turn-outline/types' + +async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + if (withOutlinePlugin) await ctx.plugin(SessionTurnOutlinePlugin) + return { ctx, session: ctx.sessions.create(SessionId('outlined')) } +} + +/** Append one human prompt; returns its seq. */ +function appendPrompt(session: Session, text: string): number { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }).seq +} + +function outlineOf(ctx: Context, session: Session): TurnOutlineProjection { + return ctx.sessionProjections.snapshot(session).values.turnOutline as TurnOutlineProjection +} + +describe('turn outline projection unit', () => { + it('serves an empty outline before any turn starts', async () => { + const { ctx, session } = await harness(true) + expect(outlineOf(ctx, session)).toEqual({ turns: [] }) + expect(ctx.sessionProjections.checkpoint(session).turnOutline) + .toEqual({ ver: 1, seq: -1, val: { turns: [] } }) + }) + + it('folds each started turn with its boundary seq and first human prompt only', async () => { + const { ctx, session } = await harness(true) + const firstBoundary = session.append('turn/start', { turn: 1 }).seq + appendPrompt(session, 'hello world') + appendPrompt(session, 'a later steer must not replace the prompt') + const secondBoundary = session.append('turn/start', { turn: 2 }).seq + appendPrompt(session, 'second prompt') + expect(outlineOf(ctx, session)).toEqual({ + turns: [ + { turn: 1, seq: firstBoundary, prompt: 'hello world' }, + { turn: 2, seq: secondBoundary, prompt: 'second prompt' }, + ], + }) + }) + + it('keeps an empty preview for a turn whose prompt never lands', async () => { + const { ctx, session } = await harness(true) + const boundary = session.append('turn/start', { turn: 1 }).seq + session.append('step/start', { turn: 1, step: 1 }) + expect(outlineOf(ctx, session)).toEqual({ turns: [{ turn: 1, seq: boundary, prompt: '' }] }) + }) + + it('collapses whitespace, joins text blocks, and caps the preview at 160 characters', async () => { + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [ + { type: 'text', text: ` first\n\nline\t${'x'.repeat(200)}` }, + { type: 'text', text: 'never reached past the budget' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const preview = outlineOf(ctx, session).turns[0]?.prompt + expect(preview).toBeDefined() + expect(preview).toMatch(/^first line x+$/) + expect(preview).toHaveLength(160) + }) + + it('ignores non-human user/message sources and pre-turn prompts', async () => { + const { ctx, session } = await harness(true) + appendPrompt(session, 'queued before any turn') + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'injected context' }], + source: { kind: 'plugin', plugin: 'test-injector', form: 'relay' }, + }), { surfaceOp: 'append' }) + expect(outlineOf(ctx, session)).toEqual({ + turns: [{ turn: 1, seq: 1, prompt: '' }], + }) + }) + + it('notifies the change feed only when the outline actually moves', async () => { + const { ctx, session } = await harness(true) + const changes: { key: string; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, _value, seq) => { + if (key === 'turnOutline') changes.push({ key, seq }) + }) + const boundarySeq = session.append('turn/start', { turn: 1 }).seq + session.append('step/start', { turn: 1, step: 1 }) + const promptSeq = appendPrompt(session, 'hello') + appendPrompt(session, 'second human message in the same turn') + session.append('step/end', { turn: 1, step: 1 }) + expect(changes).toEqual([ + { key: 'turnOutline', seq: boundarySeq }, + { key: 'turnOutline', seq: promptSeq }, + ]) + }) + + it('skips a boundary that does not advance the turn number (fabricated envelope)', () => { + const state: TurnOutlineProjection = { turns: [{ turn: 2, seq: 5, prompt: 'kept' }] } + const regressive = { + type: 'turn/start', + seq: 9, + time: 0, + data: { turn: 2 }, + } as unknown as SessionEvent + expect(turnOutlineProjectionDefinition.apply(state, regressive)).toBe(state) + }) + + it('folds turns already in the log when the plugin mounts late (lazy cell build)', async () => { + const { ctx, session } = await harness(false) + session.append('turn/start', { turn: 1 }) + appendPrompt(session, 'pre-mount prompt') + await ctx.plugin(SessionTurnOutlinePlugin) + expect(outlineOf(ctx, session).turns).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt' }]) + }) + + it('has no key without the plugin and drops it when the plugin unloads (HMR safety)', async () => { + const { ctx, session } = await harness(false) + expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = await ctx.plugin(SessionTurnOutlinePlugin) + session.append('turn/start', { turn: 1 }) + expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(true) + await fiber.dispose() + expect('turnOutline' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) + + it('rejects a persisted checkpoint whose turns are not strictly increasing', async () => { + const { ctx, session } = await harness(true) + const checkpoint = ctx.sessionProjections.checkpoint(session) + const row = checkpoint.turnOutline + expect(row).toBeDefined() + expect(() => ctx.sessionProjections.restore({ + ...checkpoint, + turnOutline: { + ...row!, + val: { turns: [{ turn: 2, seq: 1, prompt: '' }, { turn: 2, seq: 4, prompt: '' }] }, + }, + }, [], 0, session.header)).toThrow(/strictly increasing/) + expect(() => ctx.sessionProjections.restore({ + ...checkpoint, + turnOutline: { + ...row!, + val: { turns: [{ turn: 1, seq: 1, prompt: 'ok' }, { turn: 2, seq: 4, prompt: '' }] }, + }, + }, [], 0, session.header)).not.toThrow() + }) +}) diff --git a/packages/session/session-turn-outline/tsconfig.json b/packages/session/session-turn-outline/tsconfig.json new file mode 100644 index 0000000000..c87ec78f8b --- /dev/null +++ b/packages/session/session-turn-outline/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../session-projection" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 326595dfff..022f00b2f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1680,6 +1680,9 @@ importers: '@deepseek-ai/dsh-session-stats': specifier: workspace:^ version: link:../../session/session-stats + '@deepseek-ai/dsh-session-turn-outline': + specifier: workspace:^ + version: link:../../session/session-turn-outline '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -7796,6 +7799,34 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/session/session-turn-outline: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection + packages/settings/settings: dependencies: '@deepseek-ai/dsh-util-values': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index cb30c6a387..3b02f00d6b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -141,6 +141,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' }, 'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' }, 'packages/session/session-stats': { kind: 'none', reason: 'The sessionStats unit folds already-logged step boundaries into a client-facing read model and registers nothing model-facing.' }, + 'packages/session/session-turn-outline': { kind: 'none', reason: 'The turnOutline unit folds already-logged turn boundaries into a client-facing read model and registers nothing model-facing.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' }, 'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 3c6bc168d0..5a9bc4a4b0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -79,6 +79,8 @@ "@deepseek-ai/dsh-util-workspace-path": ["./packages/util/workspace-path/src/index.ts"], "@deepseek-ai/dsh-session-stats/types": ["./packages/session/session-stats/src/types.ts"], "@deepseek-ai/dsh-session-stats/client": ["./packages/session/session-stats/src/client.ts"], + "@deepseek-ai/dsh-session-turn-outline/types": ["./packages/session/session-turn-outline/src/types.ts"], + "@deepseek-ai/dsh-session-turn-outline/client": ["./packages/session/session-turn-outline/src/client.ts"], "@deepseek-ai/dsh-token-meter/client": ["./packages/llm/token-meter/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], @@ -438,6 +440,8 @@ "@deepseek-ai/dsh-session-title-first-prompt-llm/invariant": ["./packages/session/session-title-first-prompt-llm/src/invariant.ts"], "@deepseek-ai/dsh-session-title-llm": ["./packages/session/session-title-llm/src"], "@deepseek-ai/dsh-session-title-llm/invariant": ["./packages/session/session-title-llm/src/invariant.ts"], + "@deepseek-ai/dsh-session-turn-outline": ["./packages/session/session-turn-outline/src"], + "@deepseek-ai/dsh-session-turn-outline/invariant": ["./packages/session/session-turn-outline/src/invariant.ts"], "@deepseek-ai/dsh-settings": ["./packages/settings/settings/src"], "@deepseek-ai/dsh-settings/invariant": ["./packages/settings/settings/src/invariant.ts"], "@deepseek-ai/dsh-settings-file": ["./packages/settings/settings-file/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index e21c47c805..2d9dc3513c 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -180,6 +180,7 @@ { "path": "./packages/session/session-title-llm" }, { "path": "./packages/session/session-title-first-prompt-llm" }, { "path": "./packages/session/session-title-all-prompts-llm" }, + { "path": "./packages/session/session-turn-outline" }, { "path": "./packages/session/session-telemetry" }, { "path": "./packages/identity/anonymous-user-id" }, { "path": "./packages/session/session-telemetry-otel" }, From 218bb7f6452793de50c53e39cdbb5e8db463a4b1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 12:54:52 +0800 Subject: [PATCH 22/43] feat(session-controller): loadThrough deep history paging Session.loadThrough(seq) loops the existing prepend pager (200-message pages) until the window covers the target seq, with a shared low-water retarget for repeated calls, a no-progress guard against empty pages still claiming history, and loadOlder's fail-soft error posture. Busy state rides the existing loadingOlder snapshot bit. --- .../src/client/contract/session.ts | 9 ++ .../src/client/sessions/session.ts | 42 +++++++++ .../tests/session.client.spec.ts | 90 ++++++++++++++++++- .../conversation-registry.client.spec.ts | 1 + .../client-runtime/src/sessions.ts | 8 ++ 5 files changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 02214bb872..71cc72e0ab 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -119,6 +119,15 @@ export interface ISession { * @returns completion; failures land in snapshot.openState/loadingOlder. */ loadOlder(): Promise + /** + * Page history backwards until the window covers `seq` (inclusive) — the + * turn-jump loader. Repeated calls while a jump is paging lower its shared + * target and return the in-flight completion; `snapshot.loadingOlder` is + * the busy signal for the whole jump. + * @param seq - durable event seq the window must reach (a turn's `turn/start` seq). + * @returns completion once covered, exhausted, superseded, or failed soft. + */ + loadThrough(seq: number): Promise /** * Execute one slash-command line against this session's agent — pure * admission semantics (the host executor durably logs the lifecycle). diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 25d5e61082..294f33f3aa 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -38,6 +38,9 @@ import { SessionQueueMirror } from './queue-mirror.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */ +export const JUMP_PAGE_MESSAGES = 200 + /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ @@ -78,6 +81,10 @@ export class Session implements SessionFace { * passes drop all writes once the generation moves on. */ private openGeneration = 0 private loadingOlder = false + /** Shared low-water target of the running jump loop; null when no jump is paging. */ + private jumpTargetSeq: number | null = null + /** The running jump loop's completion, shared by retargeting callers. */ + private jumpPromise: Promise | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private readonly queueMirror = new SessionQueueMirror() private running = false @@ -369,6 +376,41 @@ export class Session implements SessionFace { } } + /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */ + loadThrough(seq: number): Promise { + if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve() + this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq) + if (this.jumpPromise !== null) return this.jumpPromise + // A plain single-page pull owns the busy flag; the jump does not queue + // behind it (the caller may retry once it settles). + if (this.loadingOlder) return Promise.resolve() + this.loadingOlder = true + this.notifier.markDirty() + this.jumpPromise = (async () => { + try { + while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) { + const events = this.events + if (events === undefined) return + const before = this.baseSeq + await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES }) + // No-progress guard: an empty or dropped page that still claims more + // history must end the loop, not spin it. + if (this.baseSeq >= before) return + } + } catch (error) { + if (!isRemoteFailure(error)) { + console.error('[session-controller] loadThrough failed:', error) + } + } finally { + this.jumpTargetSeq = null + this.jumpPromise = null + this.loadingOlder = false + this.notifier.markDirty() + } + })() + return this.jumpPromise + } + /** Rebuild an opened history source after address replacement. * Invalidates any in-flight open first; queue state belongs to the independently * reconnecting control stream and remains untouched. */ diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 319c315873..2bee7d33b4 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -5,7 +5,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { Session, type SessionOptions } from '../src/client/sessions/session.ts' +import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' @@ -254,6 +254,94 @@ describe('paging', () => { } }) + it('loadThrough pages repeatedly until the window covers the target seq', async () => { + const oldest = plainTurn(0, 0, '最旧问', '最旧答') + const middle = plainTurn(6, 1, '中问', '中答') + const newest = plainTurn(12, 2, '新问', '新答') + const { api, session } = makeSession() + api.onHistory = (payload) => { + if (payload.beforeSeq === undefined) return histResponse(newest, true) + return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) + } + await session.open() + + const gate = deferred>>() + api.onHistory = (payload) => { + api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) + void payload + return gate.promise + } + const jump = session.loadThrough(0) + expect(session.getSnapshot().loadingOlder).toBe(true) + gate.resolve(ok(historyValue(middle, true))) + await jump + const snapshot = session.getSnapshot() + expect(snapshot.loadingOlder).toBe(false) + expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq)) + expect(api.callsOf('session.history')).toMatchObject([ + { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES }, + { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES }, + ]) + }) + + it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => { + const { api, session } = makeSession() + await session.loadThrough(0) // cold: no-op + expect(api.calls).toEqual([]) + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.open() + const calls = api.calls.length + await session.loadThrough(6) // baseSeq is already 6 + await session.loadThrough(9) // inside the window + expect(api.calls.length).toBe(calls) + }) + + it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => { + const oldest = plainTurn(0, 0, 'a', 'b') + const middle = plainTurn(6, 1, 'c', 'd') + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true) + await session.open() + + const gate = deferred>>() + api.onHistory = () => { + api.onHistory = () => histResponse(oldest, false) + return gate.promise + } + const first = session.loadThrough(6) + const second = session.loadThrough(0) + gate.resolve(ok(historyValue(middle, true))) + await Promise.all([first, second]) + expect(eventSeqs(session)).toEqual([...oldest, ...middle].map(event => event.seq).concat([12, 13, 14, 15, 16, 17])) + expect(api.callsOf('session.history')).toHaveLength(2) + }) + + it('loadThrough stops on a page that makes no progress instead of looping', async () => { + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(12, 2, 'x', 'y'), true) + : histResponse([], true) // empty page still claiming more history + await session.open() + await session.loadThrough(0) + expect(session.getSnapshot().loadingOlder).toBe(false) + expect(api.callsOf('session.history')).toHaveLength(1) + }) + + it('loadThrough fails soft on a thrown page and clears its busy state', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true) + await session.open() + api.onHistory = () => Promise.reject(new Error('page wire down')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.loadThrough(0) + expect(errorSpy).toHaveBeenCalled() + expect(session.getSnapshot().loadingOlder).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + it('ignores loadOlder while one is in flight (single request)', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 63b2c22146..ba52e36ba6 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -51,6 +51,7 @@ function fakeSession(): SessionFace { cancel: () => Promise.reject(new Error('unused fake Session operation')), rename: () => Promise.reject(new Error('unused fake Session operation')), loadOlder: () => Promise.reject(new Error('unused fake Session operation')), + loadThrough: () => Promise.reject(new Error('unused fake Session operation')), command: () => Promise.reject(new Error('unused fake Session operation')), } } diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index da67718820..419e155af7 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -152,6 +152,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `loadThrough` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + loadThrough(): never { + throw new Error(`test session "${this.sessionId}": loadThrough is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `rename` on the fixture's session face to exercise it. * @returns never — always throws. From b3064cca771aaa116190388e38946c06f7b8c062 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 13:08:14 +0800 Subject: [PATCH 23/43] feat(ui-chat): full-session turn rail with load-and-jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail now merges the turnOutline projection with loaded-window items (view-layer only; loaded wins, outline fills mid-turn prompt previews), renders unloaded turns as dimmer marks, and clicking one holds the reader's place, pages history through the turn's seq, and lands on its row after the commit — no height estimation. Settlement repages once per head movement, then falls back to the nearest rendered turn. --- packages/client/ui-chat/package.json | 1 + packages/client/ui-chat/src/client/apply.ts | 1 + .../ui-chat/src/client/chat/ChatView.tsx | 138 +++++++++++++++--- .../src/client/chat/TurnNavigator.module.css | 17 +++ .../ui-chat/src/client/chat/TurnNavigator.tsx | 35 +++-- .../src/client/chat/turn-rail-items.ts | 79 ++++++++++ .../ui-chat/src/client/contract/slots.ts | 2 + packages/client/ui-chat/src/client/locale.ts | 2 + .../ui-chat/tests/chat-view.client.spec.tsx | 69 ++++++++- .../tests/turn-rail-items.client.spec.ts | 74 ++++++++++ packages/client/ui-chat/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/turn-rail-items.ts create mode 100644 packages/client/ui-chat/tests/turn-rail-items.client.spec.ts diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 2121f5f804..56b3e2a888 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -78,6 +78,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", + "@deepseek-ai/dsh-session-turn-outline": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index be061dff13..a23ada43d7 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -125,6 +125,7 @@ export function apply(ctx: Context): void { if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) }, loadOlder: () => { void session.loadOlder() }, + loadThrough: seq => session.loadThrough(seq), loadImage: Object.assign( (attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment), { peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) }, diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 517dccfeb3..2c41dbd057 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,10 +7,11 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { ChatSnapshot, TurnNavigationItem } from '../contract/snapshot.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' +import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -202,8 +203,8 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt, - fileMentions, useTranscriptView, t, + useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough, + loadImage, openView, chatScroll, forkAt, fileMentions, useTranscriptView, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) @@ -211,6 +212,13 @@ export function ChatView({ // both the data and its change signal: the array identity moves only when a // Turn enters, leaves, or changes its preview. const turnNavigationItems = useChat(s => s.navigation.items()) + // Host-computed whole-log outline; the merge is view-layer only (the + // conversation snapshot never carries projection values). + const turnOutline = useProjection('turnOutline') + const railItems = useMemo( + () => mergeTurnRailItems(turnNavigationItems, turnOutline), + [turnNavigationItems, turnOutline], + ) const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. @@ -295,6 +303,13 @@ export function ChatView({ /** Paging anchor: semantic row/position at click, updated by reader scrolls * while the request is pending and restored after the prepend lands. */ const anchorRef = useRef(null) + /** Unloaded-turn jump in flight: target turn plus its load-through seq. */ + const pendingJumpRef = useRef<{ turn: number; seq: number } | null>(null) + const [busyJumpTurn, setBusyJumpTurn] = useState(null) + /** Bumped when a loadThrough completion settles, after its last page's commit. */ + const [jumpSettleTick, setJumpSettleTick] = useState(0) + /** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */ + const jumpRepageHeadRef = useRef(null) const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(null) @@ -369,6 +384,9 @@ export function ChatView({ const toBottom = (el: HTMLElement): void => { anchorRef.current = null + // Returning to the live tail supersedes a jump still landing. + pendingJumpRef.current = null + setBusyJumpTurn(current => current === null ? current : null) el.scrollTop = el.scrollHeight observedTopRef.current = el.scrollTop atBottomRef.current = true @@ -377,6 +395,38 @@ export function ChatView({ setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null) } + // Land a row at the reading line and republish scroll-derived state. A + // latest-ref, so navigateToTurn's identity stays stable for the memoized rail. + const landOnRowRef = useRef<(local: HTMLElement, el: HTMLElement, row: HTMLElement, turn: number) => void>( + () => {}, + ) + landOnRowRef.current = (local, el, row, turn) => { + el.scrollTop += flowTop(row, el) - 24 + observedTopRef.current = el.scrollTop + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 + atBottomRef.current = isAtBottom + setAtBottom(isAtBottom) + setActiveTurn(turn) + const position = isAtBottom ? null : scrollPosition(local, el) + if (isAtBottom) chatScroll.save(null) + else if (position !== null) chatScroll.save(position) + } + + /** Land the pending jump once its Turn has a rendered anchor row; false while it must keep waiting. */ + const realizePendingJump = (local: HTMLElement, el: HTMLElement): boolean => { + const pending = pendingJumpRef.current + if (pending === null) return true + const item = railItems.find(candidate => candidate.turn === pending.turn) + if (item === undefined || item.anchor.kind !== 'loaded') return false + const row = anchorElement(local, item.anchor.key) + if (row === null) return false + pendingJumpRef.current = null + setBusyJumpTurn(null) + anchorRef.current = null + landOnRowRef.current(local, el, row, pending.turn) + return true + } + useLayoutEffect(() => { const local = listRef.current /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ @@ -418,6 +468,11 @@ export function ChatView({ const row = anchorElement(local, anchor.key) if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top observedTopRef.current = el.scrollTop + // A jump chunk lands here: scroll to the target once its rows exist; + // until then keep holding the reader's row for the next chunk. + if (!realizePendingJump(local, el) && row !== null) { + anchorRef.current = { key: anchor.key, top: flowTop(row, el) } + } firstSeqRef.current = firstSeq /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey @@ -439,7 +494,13 @@ export function ChatView({ followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) { + toBottom(el) + return + } + // A jump whose target committed outside the anchored-prepend path (for + // example after a mid-jump toBottom dropped the held anchor) lands here. + if (pendingJumpRef.current !== null) realizePendingJump(local, el) }) const onScrollRef = useRef(() => {}) @@ -533,6 +594,38 @@ export function ChatView({ if (!loadingOlder) anchorRef.current = null }, [loadingOlder]) + // Jump settlement: every loadThrough completion bumps the tick after its + // last page's commit. A still-pending jump is either realized now, repaged + // once per head movement (its own paging can be refused while a plain pull + // holds the busy flag), or landed on the nearest rendered Turn at or after + // the target (failure, exhausted history, or a Turn with no visible row). + useEffect(() => { + const pending = pendingJumpRef.current + const local = listRef.current + if (pending === null || local === null) return + const el = scrollerOf(local) + if (realizePendingJump(local, el)) return + const uncovered = firstSeq === null || firstSeq > pending.seq + if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) { + jumpRepageHeadRef.current = firstSeq + const held = pagingAnchor(local, el) + if (held !== null && held.dataset.chatAnchorKey !== undefined) { + anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } + } + void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) + return + } + for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) { + const turn = Number(row.dataset.chatTurn) + if (!Number.isSafeInteger(turn) || turn < pending.turn) continue + landOnRowRef.current(local, el, row, turn) + break + } + pendingJumpRef.current = null + setBusyJumpTurn(null) + // Snapshot values are read at settle time; the completion tick is the trigger. + }, [jumpSettleTick]) + const loadOlderAnchored = (): void => { const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ @@ -550,35 +643,44 @@ export function ChatView({ } // Identity feeds the memoized rail; a fresh closure per render would defeat it. - const navigateToTurn = useCallback((item: TurnNavigationItem): void => { + const navigateToTurn = useCallback((item: TurnRailItem): void => { const local = listRef.current if (local === null) return - const row = anchorElement(local, item.anchorKey) - if (row === null) return const el = scrollerOf(local) - el.scrollTop += flowTop(row, el) - 24 - observedTopRef.current = el.scrollTop + if (item.anchor.kind === 'unloaded') { + // Hold the reader's place through the paging chunks; the layout effect + // lands on the target once its rows commit. + const held = pagingAnchor(local, el) + if (held !== null && held.dataset.chatAnchorKey !== undefined) { + anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } + } + pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq } + jumpRepageHeadRef.current = null + setBusyJumpTurn(item.turn) + void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) + return + } + const row = anchorElement(local, item.anchor.key) + if (row === null) return + // A loaded-mark click supersedes any jump still landing. + pendingJumpRef.current = null + setBusyJumpTurn(current => current === null ? current : null) + landOnRowRef.current(local, el, row, item.turn) // A pending older page still has to compensate the prepended height, so // navigation moves that anchor to the new position instead of dropping it. const landed = loadingOlder ? pagingAnchor(local, el) : null anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined ? null : { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) } - const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 - atBottomRef.current = isAtBottom - setAtBottom(isAtBottom) - setActiveTurn(item.turn) - const position = isAtBottom ? null : scrollPosition(local, el) - if (isAtBottom) chatScroll.save(null) - else if (position !== null) chatScroll.save(position) - }, [loadingOlder, chatScroll]) + }, [loadingOlder, loadThrough]) return (
    diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 76a7b73e9c..af13b07ba0 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -83,11 +83,21 @@ transition: width 140ms ease, background-color 140ms ease; } +/* Ordered before hover/active so those states keep their stronger tick. */ +.markUnloaded::before { + width: 8px; + opacity: 0.6; +} + .markPreview::before { width: 18px; background: var(--dsw-alias-label-tertiary); } +.markBusy::before { + animation: dsh-turn-mark-busy 1s ease-in-out infinite; +} + .markActive::before { width: 20px; background: var(--dsw-alias-label-primary); @@ -163,6 +173,12 @@ to { opacity: 1; transform: translateX(0); } } +@keyframes dsh-turn-mark-busy { + 0%, + 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + @container (max-width: 900px) { .slot { display: none; @@ -173,6 +189,7 @@ .rail, .markPosition, .mark::before, + .markBusy::before, .preview { transition: none; animation: none; diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index 55818f85cd..3eef55269a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -2,13 +2,15 @@ import { memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { TurnNavigationItem } from '../contract/snapshot.ts' +import type { TurnRailItem } from './turn-rail-items.ts' import css from './TurnNavigator.module.css' interface TurnNavigatorProps { - readonly items: readonly TurnNavigationItem[] + readonly items: readonly TurnRailItem[] readonly activeTurn: number | null - readonly onNavigate: (item: TurnNavigationItem) => void + /** Turn whose jump is still paging history in; its mark pulses. */ + readonly busyTurn: number | null + readonly onNavigate: (item: TurnRailItem) => void readonly t: ChatViewSlotProps['t'] } @@ -43,17 +45,17 @@ function railSize(count: number): TurnRailStyle { } function itemAtPointer( - items: readonly TurnNavigationItem[], + items: readonly TurnRailItem[], rail: HTMLElement, clientY: number, -): TurnNavigationItem | undefined { +): TurnRailItem | undefined { const rect = rail.getBoundingClientRect() const usableHeight = Math.max(1, rect.height - 2 * RAIL_INSET_PX) const ratio = Math.max(0, Math.min(1, (clientY - rect.top - RAIL_INSET_PX) / usableHeight)) return items[Math.round(ratio * (items.length - 1))] } -function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) const previewId = useId() if (items.length < 2) return null @@ -81,16 +83,22 @@ function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorPr {items.map((item, index) => { const active = item.turn === activeTurn const showingPreview = item.turn === previewTurn - const markClass = active - ? `${css.mark} ${css.markActive}` - : showingPreview ? `${css.mark} ${css.markPreview}` : css.mark + const classes = [css.mark] + if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded) + if (active) classes.push(css.markActive) + else if (showingPreview) classes.push(css.markPreview) + if (item.turn === busyTurn) classes.push(css.markBusy) return (
    - ) - })} +
    { syncScrollState() }} + > +
    + {items.map((item, index) => { + const active = item.turn === activeTurn + const showingPreview = item.turn === previewTurn + const classes = [css.mark] + if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded) + if (active) classes.push(css.markActive) + else if (showingPreview) classes.push(css.markPreview) + if (item.turn === busyTurn) classes.push(css.markBusy) + return ( +
    +
    + ) + })} +
    {preview !== undefined && previewPosition !== undefined && (
@@ -108,9 +109,9 @@ None; the package never assembles or sends provider requests. These limits define what the outline describes and when the unit is absent. They are current package constraints. -- **The wire value grows with the session** — every change pushes the complete outline (whole-value rule), roughly 200 bytes per turn; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it. -- **Previews carry the prompt only** — assistant-response previews stay window-scoped in the consumer; the outline never re-reads message bodies. -- **A turn without an eligible text prompt keeps `''`** — images-only and command-only turns are navigable but labeled by number. +- **The wire value grows with the session** — every push carries the complete outline (whole-value rule), up to ~600 bytes per turn at full CJK budgets and typically far less; splitting previews into an on-demand read is deferred until sessions with many thousands of turns need it. +- **The response previews only settled turns** — it commits at `turn/end`, so an open turn (or one whose end never logged) shows a prompt-only preview until the boundary lands. +- **A turn without eligible text keeps `''`** — images-only and command-only turns are navigable but labeled by number, and a turn whose steps emit no text gets no response preview. - **Mounted only where the projection registry is composed** — other assemblies serve no `turnOutline` key, and their consumers fall back to loaded-window navigation. diff --git a/packages/session/session-turn-outline/README.zh.md b/packages/session/session-turn-outline/README.zh.md index f10a0a61d9..d60e70e4b6 100644 --- a/packages/session/session-turn-outline/README.zh.md +++ b/packages/session/session-turn-outline/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 与有界的首条提示词预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。 +`dsh-session-turn-outline` 以 `turnOutline` 投影单元提供全日志的轮次大纲——每个已开始的轮次连同其 `turn/start` seq 以及有界的提示词与最终回复预览。按窗口分页历史的客户端读取大纲即可提供会话的每一轮(无论是否已加载),并把向后分页精确定位到能载入某轮事件的 seq。在已挂载投影注册表的组合中选择它,例如以聊天轮次导航栏为参考消费者的 Web 应用包;没有注册表的装配不受影响,其消费者回退到仅按已加载窗口导航。用法与条目语义在前;折叠内部细节放在下方可折叠的开发者章节中。 ## 目录 @@ -41,9 +41,10 @@ kind: "package-reference" |---|---| | `turn` | `turn/start` 载荷里的宿主分配轮次号 | | `seq` | 该轮 `turn/start` 事件的 seq——窗口向后分页越过此 seq 即载入整轮 | -| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、160 字符封顶);合格提示词落日志前为 `''` | +| `prompt` | 该轮首条人类提示词的预览(文本块以空格连接、空白折叠、50 字符封顶且截断时补省略号——即导航卡片一行);合格提示词落日志前为 `''` | +| `response` | 该轮最后一条带文本的助手消息的预览(同样的归一化、120 字符封顶——即卡片至多三行);轮次带着助手文本结束前为 `''` | -条目按 `turn` 严格递增,wire 值是完整大纲(整值规则):消费者整体替换,从不合并。只有带人类 `user` 来源的 `user/message` 事件才会填充预览,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。 +wire 值是按 `turn` 严格递增的完整条目数组(整值规则):消费者整体替换,从不合并。提示词只从带人类 `user` 来源的 `user/message` 事件填充,注入的上下文与工具结果绝不进入导航;纯图片提示词的轮次保持 `''`,消费者按轮次号标注。回复在轮次流式期间缓冲为草稿、在 `turn/end` 落定;变更流的原始视图身份门让纯草稿变化保持安静,因此大纲每轮至多推送三次——开轮、提示词、落定回复。预览预算与聊天导航栏已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。 ### 失败与恢复 @@ -61,7 +62,7 @@ kind: "package-reference" ### 设计理念 -该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。预览随后由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。 +该单元是对已提交会话事件的纯折叠。锚定每个条目的是 `turn/start` 而非提示词 `user/message`,因为它的 seq 就是跳转的载入目标:agent loop 先记 `turn/start` 再记该轮的提示词与步骤,窗口向后分页越过该 seq 即包含整轮。提示词由首条人类 `user/message` 填充,且仅当最新条目仍为空时——同一轮内后续的人类消息(steering)保留首个预览。回复无法同样填充(`turn/end` 不带文本),所以每条带文本的 `assistant/message` 覆写状态里的草稿,`turn/end` 提交幸存者——最新的文本,与已加载导航栏 `findLast` 的语义一致。 ### 源码地图 @@ -73,9 +74,9 @@ kind: "package-reference" ### 折叠规则 -- 不相关事件返回同一状态引用;注册表的 `Object.is` 门禁保持变更流安静——大纲每轮至多变动两次。 -- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的提示词随后落在既有条目上。 -- 状态与 wire 视图是同一个值,因此持久缓存的状态 schema 就是 wire schema。 +- 不相关事件返回同一状态引用,纯草稿变化保持 `turns` 数组身份不变;注册表的两道 `Object.is` 门由此把变更流压到每轮至多三次推送。 +- 未推进轮次号的 `turn/start` 被跳过,保持大纲有序;重试边界的预览随后落在既有条目上。 +- wire 视图投影 `state.turns`;持久缓存的状态 schema 在 wire schema 外再包一个草稿字段。
@@ -108,9 +109,9 @@ kind: "package-reference" 这些限制说明大纲描述什么、单元何时缺失。它们是当前包约束。 -- **wire 值随会话增长**——每次变更推送完整大纲(整值规则),约每轮 200 字节;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。 -- **预览只含提示词**——助手回复预览仍由消费者按窗口提供;大纲从不回读消息正文。 -- **没有合格文本提示词的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注。 +- **wire 值随会话增长**——每次推送携带完整大纲(整值规则),全中文预算下每轮上限约 600 字节、通常远小于此;把预览拆成按需读取推迟到数千轮量级的会话真正需要时。 +- **回复只预览已落定的轮次**——它在 `turn/end` 提交,进行中的轮次(或从未记下结束边界的轮次)在边界落地前只有提示词预览。 +- **没有合格文本的轮次保持 `''`**——纯图片、纯命令的轮次可导航但按轮次号标注,步骤全程不产文本的轮次没有回复预览。 - **仅在组合了投影注册表时挂载**——其他装配不提供 `turnOutline` 键,其消费者回退到仅按已加载窗口导航。 diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 53a405134e..7184bf5d17 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -1,14 +1,20 @@ /** - * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries - * and first human prompts into the whole-log turn outline the chat rail - * renders for turns outside a client's paged event window. + * The `turnOutline` projection unit: a pure fold of `turn/start` boundaries, + * first human prompts, and final assistant responses into the whole-log turn + * outline the chat rail renders for turns outside a client's paged event + * window. * * `turn/start` — not the prompt `user/message` — anchors each entry because * its seq is the load-through target for a jump: the loop logs `turn/start` * before the turn's prompt and steps, so a window paged back through that seq - * contains the whole turn. The preview mirrors the rail's loaded-turn preview - * (space-joined text blocks, collapsed whitespace, 160-character cap) so a - * turn shows the same words before and after its events load. + * contains the whole turn. Previews mirror the rail's loaded-turn previews + * (space-joined text blocks, collapsed whitespace, an ellipsis when clipped) + * with budgets sized to the rail card's clamps — one prompt line, up to three + * response lines — so a turn shows the same words before and after its events + * load. The response commits at `turn/end` from a draft of the newest + * text-bearing assistant message; draft-only applies keep the `turns` array's + * identity, so the identity-gated change feed pushes at most three times per + * turn (boundary, prompt, response). * * @module @deepseek-ai/dsh-session-turn-outline/projection */ @@ -17,31 +23,40 @@ import { z } from 'zod' import type { ZodType } from 'zod' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type { TurnOutlineProjection } from './types.ts' +import type { TurnOutlineEntry, TurnOutlineState } from './types.ts' -/** Preview budget per entry, matching the rail's loaded-turn preview clamp. */ -const PREVIEW_LIMIT = 160 +/** Prompt budget: one rail-card line (13px over ~276px), ASCII worst case included. */ +const PROMPT_PREVIEW_LIMIT = 50 +/** Response budget: three rail-card lines (12px over ~276px). */ +const RESPONSE_PREVIEW_LIMIT = 120 -/** Space-join text blocks until the budget is met, then normalize and cap. */ -function promptPreview(content: SessionEvent<'user/message'>['data']['content']): string { +type MessageContent = SessionEvent<'user/message'>['data']['content'] + +/** Space-join text blocks, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */ +function preview(content: MessageContent, limit: number): string { let text = '' + let unread = false for (const block of content) { if (block.type !== 'text') continue + if (text.length >= limit * 2) { + unread = true + break + } text += text === '' ? block.text : ` ${block.text}` - if (text.length >= PREVIEW_LIMIT) break } - return text.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_LIMIT) + const normalized = text.replace(/\s+/g, ' ').trim() + if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…` + return unread ? `${normalized}…` : normalized } -const turnOutlineSchema: ZodType = z.object({ - turns: z.array(z.object({ - turn: z.number().int().nonnegative(), - seq: z.number().int().nonnegative(), - prompt: z.string().max(PREVIEW_LIMIT), - }).strict()), -}).strict().superRefine((state, context) => { +const turnOutlineEntriesSchema: ZodType = z.array(z.object({ + turn: z.number().int().nonnegative(), + seq: z.number().int().nonnegative(), + prompt: z.string().max(PROMPT_PREVIEW_LIMIT), + response: z.string().max(RESPONSE_PREVIEW_LIMIT), +}).strict()).superRefine((turns, context) => { let previous = -1 - for (const entry of state.turns) { + for (const entry of turns) { if (entry.turn <= previous) { context.addIssue({ code: 'custom', message: 'turn outline entries must be strictly increasing by turn' }) return @@ -50,24 +65,34 @@ const turnOutlineSchema: ZodType = z.object({ } }) -const EMPTY_OUTLINE: TurnOutlineProjection = { turns: [] } +const turnOutlineStateSchema: ZodType = z.object({ + turns: turnOutlineEntriesSchema, + draft: z.string().max(RESPONSE_PREVIEW_LIMIT), +}).strict() + +const EMPTY_OUTLINE: TurnOutlineState = { turns: [], draft: '' } /** The `turnOutline` unit registered on `ctx.sessionProjections` (exported for the unit spec). */ export const turnOutlineProjectionDefinition = { key: 'turnOutline', - stateVersion: 1, - stateSchema: turnOutlineSchema, + stateVersion: 2, + stateSchema: turnOutlineStateSchema, init: () => EMPTY_OUTLINE, apply: (state, event) => { - // Every uninteresting event returns the same reference (Object.is gates the change feed). + // Every uninteresting event returns the same reference (Object.is gates + // the drive), and draft-only changes keep `turns` identity (the raw-view + // identity gate then keeps the change feed quiet). switch (event.type) { case 'turn/start': { const last = state.turns.at(-1) // Order guard: a boundary that does not advance the turn number keeps - // the outline sorted, and a retried turn's prompt lands on the + // the outline sorted, and a retried turn's previews land on the // standing entry. if (last !== undefined && event.data.turn <= last.turn) return state - return { turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '' }] } + return { + turns: [...state.turns, { turn: event.data.turn, seq: event.seq, prompt: '', response: '' }], + draft: '', + } } case 'user/message': { // Only the newest turn can still be waiting for its opening human @@ -76,16 +101,28 @@ export const turnOutlineProjectionDefinition = { if (event.data.source.kind !== 'user') return state const last = state.turns.at(-1) if (last === undefined || last.prompt !== '') return state - const prompt = promptPreview(event.data.content) + const prompt = preview(event.data.content, PROMPT_PREVIEW_LIMIT) if (prompt === '') return state - return { turns: [...state.turns.slice(0, -1), { ...last, prompt }] } + return { turns: [...state.turns.slice(0, -1), { ...last, prompt }], draft: state.draft } + } + case 'assistant/message': { + // Newest text-bearing message wins; the buffer commits at turn/end. + const draft = preview(event.data.message.content, RESPONSE_PREVIEW_LIMIT) + if (draft === '' || draft === state.draft) return state + return { turns: state.turns, draft } + } + case 'turn/end': { + if (state.draft === '') return state + const last = state.turns.at(-1) + if (last === undefined || last.response === state.draft) return { turns: state.turns, draft: '' } + return { turns: [...state.turns.slice(0, -1), { ...last, response: state.draft }], draft: '' } } default: return state } }, wire: { - viewSchema: turnOutlineSchema, - view: state => state, + viewSchema: turnOutlineEntriesSchema, + view: state => state.turns, }, -} satisfies ProjectionDefinition<'turnOutline', TurnOutlineProjection> +} satisfies ProjectionDefinition<'turnOutline', TurnOutlineState> diff --git a/packages/session/session-turn-outline/src/types.ts b/packages/session/session-turn-outline/src/types.ts index 7746c58558..11e0b9c295 100644 --- a/packages/session/session-turn-outline/src/types.ts +++ b/packages/session/session-turn-outline/src/types.ts @@ -15,23 +15,33 @@ export interface TurnOutlineEntry { readonly turn: number /** The turn's `turn/start` event seq — paging a window back through this seq loads the whole turn. */ readonly seq: number - /** Bounded preview of the turn's first human prompt; `''` until an eligible prompt lands. */ + /** Bounded first-human-prompt preview (one rail-card line); `''` until an eligible prompt lands. */ readonly prompt: string + /** Bounded final-response preview (up to three rail-card lines); `''` until the turn ends with assistant text. */ + readonly response: string } -/** Whole-log turn outline: every started turn, strictly increasing by `turn`. */ -export interface TurnOutlineProjection { +/** + * Fold state: the served entries plus the open turn's response draft. The + * draft buffers the newest text-bearing assistant message until `turn/end` + * commits it, and the wire view projects only `turns` — draft-only applies + * keep that array's identity, so the change feed stays quiet between turn + * boundaries. + */ +export interface TurnOutlineState { /** Started turns in ascending turn order. */ readonly turns: readonly TurnOutlineEntry[] + /** Newest text-bearing assistant preview of the open turn; `''` outside one. */ + readonly draft: string } declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { - /** Whole-log turn outline fold state (identical to the wire view). */ - turnOutline: TurnOutlineProjection + /** Whole-log turn outline fold state (entries plus the open turn's response draft). */ + turnOutline: TurnOutlineState } interface SessionProjectionMap { - /** Every started turn with its `turn/start` seq and bounded prompt preview; see {@link TurnOutlineProjection}. */ - turnOutline: TurnOutlineProjection + /** Every started turn with its `turn/start` seq and bounded previews, strictly increasing by turn; see {@link TurnOutlineEntry}. */ + turnOutline: readonly TurnOutlineEntry[] } } diff --git a/packages/session/session-turn-outline/tests/loader-composition.spec.ts b/packages/session/session-turn-outline/tests/loader-composition.spec.ts index 9568cb83c6..e6be0b7f71 100644 --- a/packages/session/session-turn-outline/tests/loader-composition.spec.ts +++ b/packages/session/session-turn-outline/tests/loader-composition.spec.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' @@ -76,8 +76,17 @@ describe('real Loader composition', () => { content: [{ type: 'text', text: 'composed prompt' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'composed answer' }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(loaded.sessionProjections.snapshot(session).values.turnOutline) - .toEqual({ turns: [{ turn: 1, seq: boundary, prompt: 'composed prompt' }] }) + .toEqual([{ turn: 1, seq: boundary, prompt: 'composed prompt', response: 'composed answer' }]) }) it('keeps the function-plugin namespace free of a default export', () => { diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index 80703ce778..bbee4cdc3a 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -1,21 +1,23 @@ /** * The `turnOutline` projection unit: mounting the plugin beside the * projection registry serves the whole-log turn outline (turn number, - * `turn/start` seq, bounded first-prompt preview); compositions without the - * registry are unaffected; unmounting the plugin removes the key (HMR - * safety). Narrow fold paths with fabricated envelopes (non-human sources, + * `turn/start` seq, bounded prompt and final-response previews); + * compositions without the registry are unaffected; unmounting the plugin + * removes the key (HMR safety). The response buffers as a draft and commits + * at `turn/end`, keeping the identity-gated change feed at three pushes per + * turn. Narrow fold paths with fabricated envelopes (non-human sources, * regressive turn numbers) run against the exported definition directly. */ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline' import { turnOutlineProjectionDefinition } from '@deepseek-ai/dsh-session-turn-outline/src/projection.ts' -import type { TurnOutlineProjection } from '@deepseek-ai/dsh-session-turn-outline/types' +import type { TurnOutlineEntry, TurnOutlineState } from '@deepseek-ai/dsh-session-turn-outline/types' async function harness(withOutlinePlugin: boolean): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() @@ -33,54 +35,78 @@ function appendPrompt(session: Session, text: string): number { }), { surfaceOp: 'append' }).seq } -function outlineOf(ctx: Context, session: Session): TurnOutlineProjection { - return ctx.sessionProjections.snapshot(session).values.turnOutline as TurnOutlineProjection +/** Append one assembled assistant message with a single text block. */ +function appendAssistant(session: Session, turn: number, step: number, text: string): void { + session.append('assistant/message', { + turn, + step, + message: createAssistantMessage({ + content: [{ type: 'text', text }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) +} + +function endTurn(session: Session, turn: number): number { + return session.append('turn/end', { turn, reason: { kind: 'completed' } }).seq +} + +function outlineOf(ctx: Context, session: Session): readonly TurnOutlineEntry[] { + return ctx.sessionProjections.snapshot(session).values.turnOutline as readonly TurnOutlineEntry[] } describe('turn outline projection unit', () => { it('serves an empty outline before any turn starts', async () => { const { ctx, session } = await harness(true) - expect(outlineOf(ctx, session)).toEqual({ turns: [] }) + expect(outlineOf(ctx, session)).toEqual([]) expect(ctx.sessionProjections.checkpoint(session).turnOutline) - .toEqual({ ver: 1, seq: -1, val: { turns: [] } }) + .toEqual({ ver: 2, seq: -1, val: { turns: [], draft: '' } }) }) - it('folds each started turn with its boundary seq and first human prompt only', async () => { + it('folds each turn with its boundary seq, first prompt, and turn-end response', async () => { const { ctx, session } = await harness(true) const firstBoundary = session.append('turn/start', { turn: 1 }).seq appendPrompt(session, 'hello world') appendPrompt(session, 'a later steer must not replace the prompt') + appendAssistant(session, 1, 1, 'first draft answer') + appendAssistant(session, 1, 2, 'final answer of turn one') + endTurn(session, 1) const secondBoundary = session.append('turn/start', { turn: 2 }).seq appendPrompt(session, 'second prompt') - expect(outlineOf(ctx, session)).toEqual({ - turns: [ - { turn: 1, seq: firstBoundary, prompt: 'hello world' }, - { turn: 2, seq: secondBoundary, prompt: 'second prompt' }, - ], - }) + expect(outlineOf(ctx, session)).toEqual([ + { turn: 1, seq: firstBoundary, prompt: 'hello world', response: 'final answer of turn one' }, + { turn: 2, seq: secondBoundary, prompt: 'second prompt', response: '' }, + ]) }) - it('keeps an empty preview for a turn whose prompt never lands', async () => { + it('keeps the response empty while its turn is still open (draft only commits at turn/end)', async () => { const { ctx, session } = await harness(true) - const boundary = session.append('turn/start', { turn: 1 }).seq - session.append('step/start', { turn: 1, step: 1 }) - expect(outlineOf(ctx, session)).toEqual({ turns: [{ turn: 1, seq: boundary, prompt: '' }] }) + session.append('turn/start', { turn: 1 }) + appendPrompt(session, 'prompt') + appendAssistant(session, 1, 1, 'streamed but unsettled') + expect(outlineOf(ctx, session)[0]?.response).toBe('') + expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled') + endTurn(session, 1) + expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled') }) - it('collapses whitespace, joins text blocks, and caps the preview at 160 characters', async () => { + it('collapses whitespace and caps previews at their card budgets with an ellipsis', async () => { const { ctx, session } = await harness(true) session.append('turn/start', { turn: 1 }) session.append('user/message', createUserMessage({ content: [ - { type: 'text', text: ` first\n\nline\t${'x'.repeat(200)}` }, + { type: 'text', text: ` spaced\n\nprompt\t${'p'.repeat(80)}` }, { type: 'text', text: 'never reached past the budget' }, ], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const preview = outlineOf(ctx, session).turns[0]?.prompt - expect(preview).toBeDefined() - expect(preview).toMatch(/^first line x+$/) - expect(preview).toHaveLength(160) + appendAssistant(session, 1, 1, `answer ${'r'.repeat(200)}`) + endTurn(session, 1) + const entry = outlineOf(ctx, session)[0] + expect(entry?.prompt).toMatch(/^spaced prompt p+…$/) + expect(entry?.prompt).toHaveLength(50) + expect(entry?.response).toMatch(/^answer r+…$/) + expect(entry?.response).toHaveLength(120) }) it('ignores non-human user/message sources and pre-turn prompts', async () => { @@ -91,30 +117,32 @@ describe('turn outline projection unit', () => { content: [{ type: 'text', text: 'injected context' }], source: { kind: 'plugin', plugin: 'test-injector', form: 'relay' }, }), { surfaceOp: 'append' }) - expect(outlineOf(ctx, session)).toEqual({ - turns: [{ turn: 1, seq: 1, prompt: '' }], - }) + expect(outlineOf(ctx, session)).toEqual([ + { turn: 1, seq: 1, prompt: '', response: '' }, + ]) }) - it('notifies the change feed only when the outline actually moves', async () => { + it('pushes at most three times per turn: boundary, prompt, and settled response', async () => { const { ctx, session } = await harness(true) - const changes: { key: string; seq: number }[] = [] - ctx.sessionProjections.onChanged((_session, key, _value, seq) => { - if (key === 'turnOutline') changes.push({ key, seq }) + const changes: { seq: number; last: TurnOutlineEntry | undefined }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + if (key !== 'turnOutline') return + changes.push({ seq, last: (value as readonly TurnOutlineEntry[]).at(-1) }) }) const boundarySeq = session.append('turn/start', { turn: 1 }).seq session.append('step/start', { turn: 1, step: 1 }) const promptSeq = appendPrompt(session, 'hello') appendPrompt(session, 'second human message in the same turn') - session.append('step/end', { turn: 1, step: 1 }) - expect(changes).toEqual([ - { key: 'turnOutline', seq: boundarySeq }, - { key: 'turnOutline', seq: promptSeq }, - ]) + appendAssistant(session, 1, 1, 'draft one') + appendAssistant(session, 1, 2, 'draft two') + session.append('step/end', { turn: 1, step: 2 }) + const endSeq = endTurn(session, 1) + expect(changes.map(change => change.seq)).toEqual([boundarySeq, promptSeq, endSeq]) + expect(changes.at(-1)?.last?.response).toBe('draft two') }) it('skips a boundary that does not advance the turn number (fabricated envelope)', () => { - const state: TurnOutlineProjection = { turns: [{ turn: 2, seq: 5, prompt: 'kept' }] } + const state: TurnOutlineState = { turns: [{ turn: 2, seq: 5, prompt: 'kept', response: '' }], draft: '' } const regressive = { type: 'turn/start', seq: 9, @@ -129,7 +157,7 @@ describe('turn outline projection unit', () => { session.append('turn/start', { turn: 1 }) appendPrompt(session, 'pre-mount prompt') await ctx.plugin(SessionTurnOutlinePlugin) - expect(outlineOf(ctx, session).turns).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt' }]) + expect(outlineOf(ctx, session)).toEqual([{ turn: 1, seq: 0, prompt: 'pre-mount prompt', response: '' }]) }) it('has no key without the plugin and drops it when the plugin unloads (HMR safety)', async () => { @@ -151,14 +179,26 @@ describe('turn outline projection unit', () => { ...checkpoint, turnOutline: { ...row!, - val: { turns: [{ turn: 2, seq: 1, prompt: '' }, { turn: 2, seq: 4, prompt: '' }] }, + val: { + turns: [ + { turn: 2, seq: 1, prompt: '', response: '' }, + { turn: 2, seq: 4, prompt: '', response: '' }, + ], + draft: '', + }, }, }, [], 0, session.header)).toThrow(/strictly increasing/) expect(() => ctx.sessionProjections.restore({ ...checkpoint, turnOutline: { ...row!, - val: { turns: [{ turn: 1, seq: 1, prompt: 'ok' }, { turn: 2, seq: 4, prompt: '' }] }, + val: { + turns: [ + { turn: 1, seq: 1, prompt: 'ok', response: 'done' }, + { turn: 2, seq: 4, prompt: '', response: '' }, + ], + draft: '', + }, }, }, [], 0, session.header)).not.toThrow() }) From 7c58a95ebb86430a90d3555b0abc7f7e47d307b1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 14:39:39 +0800 Subject: [PATCH 30/43] test(session-turn-outline): cover fold edges and deflake settle assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge-branch coverage for the preview reading bound, repeated and empty drafts, draftless turn ends, orphan-draft clearing, and same-response recommits (the CI per-file gate exercises them); the jump-settle spec now asserts only the busy lifecycle after settlement — jsdom's zero geometry made the rAF active-turn resync timing-dependent under coverage instrumentation, and the landing position contract lives in the browser e2e. --- .../ui-chat/tests/chat-view.client.spec.tsx | 5 ++- .../tests/projection.spec.ts | 44 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 14489a3d37..6755af45ba 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -720,8 +720,11 @@ describe('ChatView', () => { // runs the final landing correction after the load-earlier button leaves. expect(first.getAttribute('aria-busy')).toBe('true') await act(async () => { releaseJump?.() }) + // Only the busy lifecycle is asserted after settlement: jsdom's zero + // geometry makes the rAF active-turn resync read "at bottom" and hand the + // mark to the last turn, so aria-current here is timing-dependent under + // instrumentation; the landing position contract lives in the browser e2e. expect(first.getAttribute('aria-busy')).toBeNull() - expect(first.getAttribute('aria-current')).toBe('true') }) it('hands a windowless tool result to the Tool seat with an empty tool name', () => { diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index bbee4cdc3a..3749fe554c 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -141,6 +141,50 @@ describe('turn outline projection unit', () => { expect(changes.at(-1)?.last?.response).toBe('draft two') }) + it('keeps quiet on a draftless turn end and an empty in-turn prompt', async () => { + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + // Whitespace-only prompt text normalizes to nothing: the entry stays unlabeled. + appendPrompt(session, ' \t ') + endTurn(session, 1) + expect(outlineOf(ctx, session)).toEqual([{ turn: 1, seq: 0, prompt: '', response: '' }]) + }) + + it('bounds preview reading and keeps repeated or empty drafts quiet (fabricated envelopes)', () => { + const def = turnOutlineProjectionDefinition + const assistant = (blocks: readonly unknown[]): SessionEvent => ({ + type: 'assistant/message', + seq: 9, + time: 0, + data: { message: { content: blocks } }, + }) as unknown as SessionEvent + const base: TurnOutlineState = { turns: [{ turn: 1, seq: 0, prompt: 'p', response: '' }], draft: '' } + // Non-text blocks are skipped; whitespace-heavy short blocks cross the raw + // reading bound early, so the collapsed (short) draft still marks the + // unread remainder with an ellipsis. + const airy = Array.from({ length: 40 }, (_, index) => ({ type: 'text', text: `w${String(index)}${' '.repeat(20)}` })) + const buffered = def.apply(base, assistant([{ type: 'tool-call' }, ...airy])) + expect(buffered.draft.startsWith('w0 w1 ')).toBe(true) + expect(buffered.draft.endsWith('…')).toBe(true) + expect(buffered.draft.length).toBeLessThan(120) + // The same draft again, or a text-free message, changes nothing. + expect(def.apply(buffered, assistant([{ type: 'tool-call' }, ...airy]))).toBe(buffered) + expect(def.apply(buffered, assistant([{ type: 'text', text: ' ' }]))).toBe(buffered) + // A draft with no entry to commit into clears itself at the boundary… + const end = { + type: 'turn/end', + seq: 11, + time: 0, + data: { turn: 1, reason: { kind: 'completed' } }, + } as unknown as SessionEvent + expect(def.apply({ turns: [], draft: 'orphan' }, end)).toEqual({ turns: [], draft: '' }) + // …and a re-settled identical response keeps the entries' identity. + const settled: TurnOutlineState = { turns: [{ turn: 1, seq: 0, prompt: 'p', response: 'done' }], draft: 'done' } + const recommitted = def.apply(settled, end) + expect(recommitted.turns).toBe(settled.turns) + expect(recommitted.draft).toBe('') + }) + it('skips a boundary that does not advance the turn number (fabricated envelope)', () => { const state: TurnOutlineState = { turns: [{ turn: 2, seq: 5, prompt: 'kept', response: '' }], draft: '' } const regressive = { From db5417ff6fa0c528584f3b00401f205969ea0a3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 18:09:02 +0800 Subject: [PATCH 31/43] fix(session-controller): keep refused jumps from parking a stale target loadThrough now assigns its low-water target only when it owns the loop (retargeting stays inside the running-jump branch): a call refused while a plain load-earlier pull holds the pager no longer leaves jumpTargetSeq behind to drag a later jump all the way to the head. The loop also carries the doOpen stale-pass guard so a mid-flight resync stops it instead of paging the new stream generation toward the old target. --- .../src/client/sessions/session.ts | 17 +++++-- .../tests/session.client.spec.ts | 51 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 294f33f3aa..6e33412cf9 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -379,16 +379,27 @@ export class Session implements SessionFace { /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */ loadThrough(seq: number): Promise { if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve() - this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq) - if (this.jumpPromise !== null) return this.jumpPromise + if (this.jumpPromise !== null) { + // Retarget the running loop to the lowest requested seq. + this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq) + return this.jumpPromise + } // A plain single-page pull owns the busy flag; the jump does not queue - // behind it (the caller may retry once it settles). + // behind it (the caller retries once it settles) and must leave no + // target behind — only the loop's finally clears that field, and no + // loop starts here. if (this.loadingOlder) return Promise.resolve() + this.jumpTargetSeq = seq this.loadingOlder = true this.notifier.markDirty() + // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the + // stream generation; this pass then stops instead of paging the new + // generation toward its old target. + const generation = this.openGeneration this.jumpPromise = (async () => { try { while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) { + if (generation !== this.openGeneration) return const events = this.events if (events === undefined) return const before = this.baseSeq diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 2bee7d33b4..899a3f7745 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -316,6 +316,57 @@ describe('paging', () => { expect(api.callsOf('session.history')).toHaveLength(2) }) + it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => { + const middle = plainTurn(6, 1, 'c', 'd') + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(12, 2, 'e', 'f'), true) + await session.open() + + // A plain single-page pull holds the busy flag while the jump is refused. + const gate = deferred>>() + api.onHistory = () => gate.promise + const older = session.loadOlder() + await session.loadThrough(0) // refused: must not park seq 0 anywhere + gate.resolve(ok(historyValue(middle, true))) + await older + + // A later jump to a nearer seq pages exactly to it — a leaked 0 target + // would keep pulling three-event pages all the way to the head. + api.onHistory = (payload) => { + const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3 + return histResponse( + [ev.user(start, `u${String(start)}`), ev.user(start + 1, `u${String(start + 1)}`), ev.user(start + 2, `u${String(start + 2)}`)], + start > 0, + ) + } + await session.loadThrough(4) + // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a + // third call at beforeSeq 3 and pull the head to 0. + expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq)) + .toEqual([12, 6]) + expect(eventSeqs(session)[0]).toBe(3) + }) + + it('loadThrough stops paging when the event stream generation moves mid-loop', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true) + await session.open() + + const gate = deferred>>() + api.onHistory = () => gate.promise + const jump = session.loadThrough(0) + // The address is rebuilt while the first page is in flight. + api.onHistory = () => histResponse(plainTurn(12, 2, 'x', 'y'), true) + const rebuilt = session.resync() + gate.resolve(ok(historyValue(plainTurn(6, 1, 'c', 'd'), true))) + await jump + await rebuilt + // The stale loop must not page the new generation toward its old target: + // history calls are the gated page and the resync tail only. + expect(api.callsOf('session.history')).toHaveLength(1) + expect(session.getSnapshot().loadingOlder).toBe(false) + }) + it('loadThrough stops on a page that makes no progress instead of looping', async () => { const { api, session } = makeSession() api.onHistory = payload => payload.beforeSeq === undefined From 39b90961c73f2f884a031b08f0c37c57ae354c5b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 21:39:36 +0800 Subject: [PATCH 32/43] fix(ui-chat): hold jumps while a plain pull owns the pager A jump clicked while the Load-earlier pull was in flight fell through the settle effect's nearest-turn fallback and landed on the wrong row. The effect now keeps the pending jump (busy pulse stays) while loadingOlder is true and a retry tick re-issues loadThrough when the pull settles. Also repins the long-interactions e2e rail block to the outline-rail semantics (fixed mark pitch, load-and-jump labels) and covers the loadThrough forwarding paths in apply-inject and the client-runtime stub. --- apps/web/tests/chat-long-interactions.e2e.ts | 57 ++++++++++--------- .../ui-chat/src/client/chat/ChatView.tsx | 35 ++++++++---- .../tests/apply-inject.client.spec.tsx | 4 ++ .../ui-chat/tests/chat-view.client.spec.tsx | 23 ++++++++ .../tests/runtime.client.spec.tsx | 1 + 5 files changed, 81 insertions(+), 39 deletions(-) diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 5e3c02a436..55829bd4d5 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -195,43 +195,44 @@ describe('web e2e: long Chat interaction contract', () => { const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' }) await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 }) - const initialTurnButtons = turnNavigation.getByRole('button') - const initialTurnCount = await initialTurnButtons.count() - expect(initialTurnCount).toBeGreaterThan(1) - expect(await initialTurnButtons.last().getAttribute('aria-current')).toBe('true') - const firstTurnButton = initialTurnButtons.first() - const firstTurnLabel = await firstTurnButton.getAttribute('aria-label') - if (firstTurnLabel === null) throw new Error('first Turn navigation mark has no accessible label') - const firstTurn = Number(firstTurnLabel.match(/^Jump to turn (\d+)$/)?.[1]) - expect(Number.isSafeInteger(firstTurn)).toBe(true) + // The whole-log outline offers every fixture turn before any paging, with + // the live tail mark current. + const marks = turnNavigation.getByRole('button') + await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS) + expect(await marks.last().getAttribute('aria-current')).toBe('true') + // The oldest turn is an unloaded mark whose outline preview already + // carries both the prompt and the settled response. + const firstTurnButton = turnNavigation + .getByRole('button', { name: 'Load and jump to turn 1', exact: true }) await firstTurnButton.focus() const preview = page.getByRole('tooltip') await preview.waitFor({ state: 'visible', timeout: 5_000 }) - // The first loaded Turn may begin mid-Turn at a page boundary. Its mark is - // still useful with the loaded response and gains the prompt after prepend. - expect(await preview.textContent()).toContain(`Turn ${String(firstTurn)}`) - expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn)) + expect(await preview.textContent()).toContain(FIXTURE.markers.user(1)) + expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1)) const firstTurnPosition = await firstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-position') ?? '' + button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' )) - expect(firstTurnPosition).toBe('0%') + expect(firstTurnPosition).toBe('0px') const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true }) + const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / }) + const loadedBefore = await loadedMarks.count() await loadEarlier.click() - await expect.poll(() => turnNavigation.getByRole('button').count(), { timeout: 15_000 }) - .toBeGreaterThan(initialTurnCount) - const stableFirstTurnButton = turnNavigation.getByRole('button', { name: firstTurnLabel }) - expect(await stableFirstTurnButton.evaluate(button => ( - button.parentElement?.style.getPropertyValue('--turn-position') ?? '' - ))).not.toBe(firstTurnPosition) - await stableFirstTurnButton.focus() - await expect.poll(() => preview.textContent(), { timeout: 5_000 }) - .toContain(FIXTURE.markers.user(firstTurn)) - expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(firstTurn)) - await stableFirstTurnButton.press('Enter') - await expect.poll(() => stableFirstTurnButton.getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + // Paging converts marks to their loaded form without moving the + // fixed-pitch ladder. + await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore) + expect(await firstTurnButton.evaluate(button => ( + button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? '' + ))).toBe(firstTurnPosition) + // Activating the still-unloaded oldest mark pages the rest in and lands + // on the turn's own row. + await firstTurnButton.focus() + await firstTurnButton.press('Enter') + const firstLoaded = turnNavigation.getByRole('button', { name: 'Jump to turn 1', exact: true }) + await firstLoaded.waitFor({ timeout: 60_000 }) + await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true') await expect.poll( - () => page.locator(`[data-chat-turn="${String(firstTurn)}"][data-chat-flow-kind="user"]`).count(), + () => page.locator('[data-chat-turn="1"][data-chat-flow-kind="user"]').count(), { timeout: 5_000 }, ).toBe(1) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0b422d0281..42aada1017 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -616,10 +616,12 @@ export function ChatView({ }, [loadingOlder]) // Jump settlement: every loadThrough completion bumps the tick after its - // last page's commit. A still-pending jump is either realized now, repaged - // once per head movement (its own paging can be refused while a plain pull - // holds the busy flag), or landed on the nearest rendered Turn at or after - // the target (failure, exhausted history, or a Turn with no visible row). + // last page's commit, and a plain pull's loadingOlder flip re-settles a + // jump it made wait. A still-pending jump is realized now, held while a + // plain load-earlier pull owns the pager (its completion retries below), + // repaged once per head movement, or landed on the nearest rendered Turn + // at or after the target (failure, exhausted history, or a Turn with no + // visible row). useEffect(() => { const pending = pendingJumpRef.current const local = listRef.current @@ -629,14 +631,19 @@ export function ChatView({ // commit, so the target row cannot drift once the jump clears. if (realizePendingJump(local, el, true)) return const uncovered = firstSeq === null || firstSeq > pending.seq - if (uncovered && hasMore && !loadingOlder && jumpRepageHeadRef.current !== firstSeq) { - jumpRepageHeadRef.current = firstSeq - const held = pagingAnchor(local, el) - if (held !== null && held.dataset.chatAnchorKey !== undefined) { - anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } + if (uncovered && hasMore) { + // A plain pull owns the pager right now: hold the jump (busy stays) + // instead of degrading to a wrong landing. + if (loadingOlder) return + if (jumpRepageHeadRef.current !== firstSeq) { + jumpRepageHeadRef.current = firstSeq + const held = pagingAnchor(local, el) + if (held !== null && held.dataset.chatAnchorKey !== undefined) { + anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) } + } + void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) + return } - void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) }) - return } for (const row of local.querySelectorAll('[data-chat-turn]:not([hidden])')) { const turn = Number(row.dataset.chatTurn) @@ -649,6 +656,12 @@ export function ChatView({ // Snapshot values are read at settle time; the completion tick is the trigger. }, [jumpSettleTick]) + // A jump held while a plain pull owned the pager waits in the effect + // above; the pull's completion is its retry signal. + useEffect(() => { + if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick(tick => tick + 1) + }, [loadingOlder]) + const loadOlderAnchored = (): void => { const local = listRef.current /* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */ diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 052c2b3bf8..98b477c57f 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -35,6 +35,7 @@ type ChatActions = ChatInstance['actions'] function sessionFakeFor() { return { loadOlder: vi.fn(() => Promise.resolve()), + loadThrough: vi.fn(() => Promise.resolve()), readAttachment: vi.fn(() => Promise.resolve({ ok: true, value: { attachment: ATTACHMENT, data: Uint8Array.of(1) }, @@ -92,6 +93,9 @@ describe('Chat inject API', () => { injected.loadOlder() expect(b.session.loadOlder).toHaveBeenCalledOnce() + void injected.loadThrough(42) + expect(b.session.loadThrough).toHaveBeenCalledWith(42) + injected.forkAt(17) await vi.waitFor(() => { expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 6755af45ba..9ebacea1de 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -657,6 +657,29 @@ describe('ChatView', () => { await act(async () => { releaseJump?.() }) }) + it('holds a jump issued while a plain pull owns the pager and resumes it when the pull settles', async () => { + const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)] + const h = makeHarness({ nodes: later }, { hasMore: true, loadingOlder: true }) + h.setOutline([ + { turn: 1, seq: 0, prompt: 'first prompt', response: '' }, + { turn: 3, seq: 8, prompt: 'third prompt', response: '' }, + ]) + const view = render() + const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' }) + fireEvent.click(first) + // The session-side guard refuses the busy-pager jump instantly, yet the + // mark stays busy instead of degrading to the nearest loaded turn. + await act(async () => {}) + expect(h.loadThrough.mock.calls).toEqual([[0]]) + expect(first.getAttribute('aria-busy')).toBe('true') + + // The plain pull settles: the flip re-settles the jump, which repages. + act(() => { h.setSession({ loadingOlder: false }) }) + await act(async () => {}) + expect(h.loadThrough.mock.calls).toEqual([[0], [0]]) + expect(first.getAttribute('aria-busy')).toBeNull() + }) + it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => { const h = makeHarness( { nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] }, diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 5216f778c2..d16175f9b5 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -432,6 +432,7 @@ describe('fixture session face', () => { expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) + expect(() => bare.loadThrough()).toThrow(/loadThrough is not stubbed/) expect(() => bare.rename()).toThrow(/rename is not stubbed/) const submission = bare.beginSubmission() expect(submission.requestId).toBe('test-submission-1') From 8322f804cb179627e5cb153d6d8a6dc18fb9cbd3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 21:56:19 +0800 Subject: [PATCH 33/43] fix(session-projection): advance the view dedup baseline on every change The change feed stamped lastView only when a listener was subscribed, so a value change during a listener-free window (HMR swap) froze the baseline and a later transition back to the old value was silently deduplicated. The baseline now advances on every changed state, heard or not; broadcast still only happens with listeners. Docs, catalog, and the feature note follow the corrected semantics. --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +-- docs/subsystems/session-projection.md | 10 +++--- docs/subsystems/session-projection.zh.md | 10 +++--- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection/README.i18n.yaml | 4 +-- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 30 +++++++++------- .../session-projection/tests/registry.spec.ts | 34 +++++++++++++++++++ 12 files changed, 73 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index cd2ef7bb8f..dc48dfa503 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: 7fa2ba4ee2a09a78b277b238d215413e60dc9a54 -2026-08-30-web-turn-rail-outline-jump.zh.md: 544c43715217e1beda925eb6919cfbd3d5df3907 +2026-08-30-web-turn-rail-outline-jump.md: b2803aebf54c342c08da92131db7617b28ad1620 +2026-08-30-web-turn-rail-outline-jump.zh.md: 547e708a76e2205f7e2a1c83c9b3330975136393 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index 7fa2ba4ee2..b2803aebf5 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the last delivered one and stays quiet when `Object.is`-identical. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the unit's previous projection and stays quiet when `Object.is`-identical; the baseline advances on every change, heard or not, so a later listener generation still sees every value transition. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index 544c437152..547e708a76 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与上一次交付的值比较,`Object.is` 相同即保持安静。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与该单元上一次投影值比较,`Object.is` 相同即保持安静;基线随每次变更前进、有无监听者都一样,后来的监听器换代仍能看到每次值转变。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 77c585ceca..63a040c196 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: ee6b301bde49986fb24e75a8de7fec7b7fc41688 -session-projection.zh.md: 36b6c874b440165ec56dddc938aff030fcc69b49 +session-projection.md: b7709b8d6254166afff08688c463161998731864 +session-projection.zh.md: 71da679e0cf50e6527889fd209a43291371ce313 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index ee6b301bde..b7709b8d62 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -86,9 +86,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the last - * delivered one does not fire, so a unit can buffer working fields in state - * behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the last value the feed delivered, so a unit can buffer working fields in state behind an identity-stable projection; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the unit's previous projection (the baseline advances with every change, heard or not), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 36b6c874b4..71da679e0c 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -86,9 +86,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the last - * delivered one does not fire, so a unit can buffer working fields in state - * behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与变更流上一次交付的值 `Object.is` 相同,因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与该单元上一次投影值 `Object.is` 相同(基线随每次变更前进,无论有无监听者),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f1a53af785..a55f3bf514 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the last delivered one (identity-stable projections stay quiet). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c197c6da85..7a6652ec0e 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 0268f05ef49c27082478e19941e5b3888cbce46e -README.zh.md: 286aeebf0ffdf5b5d1c4eeadc00d9df64c26daf7 +README.md: c498d6f163d704467f035f07e3da1ce1d484a163 +README.zh.md: eff47a64f2547e56642527cffb05e219d18397bf diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 0268f05ef4..c498d6f163 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the last delivered one stays quiet (so a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the unit's previous projection stays quiet, with the baseline advancing on every change, heard or not (so a unit can buffer working fields behind an identity-stable projection without silencing later listener generations). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 286aeebf0f..eff47a64f2 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上次交付相同的单元同样保持安静(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与该单元上一次投影值相同的同样保持安静,且基线随每次变更前进、有无监听者都一样(单元因此可以把工作字段缓冲在身份稳定的投影之后,而不会静默掉后来监听者的值变化)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index f48df406ec..3e3879df23 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -86,9 +86,9 @@ export interface ProjectionDefinition< * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the last - * delivered one does not fire, so a unit can buffer working fields in state - * behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ export type ProjectionChangeListener = ( session: Session, @@ -145,10 +145,11 @@ interface UnitCell { /** Seq of the last event passed through `apply` (regardless of change). */ observedSeq: number /** - * Raw (pre-validation) `view` output the change feed last delivered, when - * it has delivered one. A changed state whose raw view is `Object.is` to - * this stays quiet, so a unit can buffer working fields in state by - * keeping its wire projection identity-stable. + * Raw (pre-validation) `view` output of the last changed state, stamped + * whether or not a listener heard it. A changed state whose raw view is + * `Object.is` to this stays quiet, so a unit can buffer working fields in + * state by keeping its wire projection identity-stable — and a listener + * subscribing later still sees every value transition since this baseline. */ lastView?: { raw: unknown } } @@ -177,7 +178,8 @@ interface Registration { * every registered unit's `apply` (eager drive), and a changed state * reference in a client-visible unit notifies the change feed with the * schema-validated view — unless the raw view output is `Object.is`-identical - * to the last delivered one (identity-stable projections stay quiet). + * to the unit's previous projection (identity-stable projections stay quiet; + * the baseline advances with every change, heard or not). * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -639,13 +641,17 @@ export class SessionProjectionRegistry extends Service { const changed = !Object.is(next, cell.state) cell.state = next cell.observedSeq = event.seq - if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { + if (changed && registration.def.wire !== undefined) { // Identity gate on the raw view: a changed state whose projection is - // reference-identical to the last delivered one stays quiet, so a - // unit can buffer working fields without spamming the feed. + // reference-identical to the previous one stays quiet, so a unit can + // buffer working fields without spamming the feed. The baseline is + // stamped on every change — listeners or none — so a later listener + // generation cannot be silenced by a value the unobserved state + // passed through and returned to. const raw = registration.def.wire.view(cell.state) - if (cell.lastView !== undefined && Object.is(cell.lastView.raw, raw)) continue + const identical = cell.lastView !== undefined && Object.is(cell.lastView.raw, raw) cell.lastView = { raw } + if (identical || this.listeners.size === 0) continue const value = registration.def.wire.viewSchema.parse(raw) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 7a421a2f9f..63d28e68fe 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -20,11 +20,13 @@ declare module '@deepseek-ai/dsh-session-projection/types' { 'test/marks': MarksState 'test/count': number 'test/buffered': { marks: string[]; draft: string } + 'test/label': string } interface SessionProjectionMap { 'test/marks': { marks: string[] } 'test/buffered': string[] + 'test/label': string } } @@ -149,6 +151,38 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) }) + it('advances the dedup baseline while unobserved, so a later listener hears a return to an old value', async () => { + const { ctx, session } = await harness() + // A primitive-valued view compares by value under Object.is (the title + // unit's shape), which is exactly where a stale baseline could silence a + // real transition. + ctx.sessionProjections.register({ + key: 'test/label', + stateSchema: z.string(), + init: () => '', + apply: (state, event) => (event.type === 'test/mark' ? event.data.marks[0] ?? '' : state), + wire: { viewSchema: z.string(), view: state => state }, + stateVersion: 1, + }) + const first: string[] = [] + const stop = ctx.sessionProjections.onChanged((_session, key, value) => { + if (key === 'test/label') first.push(value as string) + }) + mark(session, ['A']) + stop() + // Unobserved transition away from 'A'… + mark(session, ['B']) + // …then a new listener generation subscribes and the value returns: with + // a baseline frozen at 'A' this delivery would be silenced. + const second: string[] = [] + ctx.sessionProjections.onChanged((_session, key, value) => { + if (key === 'test/label') second.push(value as string) + }) + mark(session, ['A']) + expect(first).toEqual(['A']) + expect(second).toEqual(['A']) + }) + it('drives independently per session (cells are per-session watermarks)', async () => { const { ctx, session } = await harness() const other = ctx.sessions.create() From b7053eba799f374068ab9d36c87aab50d94392d5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 30 Aug 2026 22:13:26 +0800 Subject: [PATCH 34/43] refactor(session-turn-outline): bound oversized preview blocks, degrade malformed previews preview() now slices a single text block to limit * 2 before joining and normalizing, so one multi-megabyte block no longer pays a full-string pass; the host projection and the client turn-navigation helper stay mirrored. outlineEntry keeps dropping entries with damaged turn/seq (marks cannot exist or jump without them) but degrades malformed prompt/response previews to empty strings so the turn stays navigable. --- .../ui-chat/src/client/chat/turn-rail-items.ts | 11 ++++++++--- .../client/conversation-nodes/turn-navigation.ts | 11 ++++++++++- .../ui-chat/tests/turn-rail-items.client.spec.ts | 7 ++++--- .../session-turn-outline/src/projection.ts | 11 ++++++++++- .../tests/projection.spec.ts | 16 ++++++++++++++++ 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts index c7ce212563..98089e83c3 100644 --- a/packages/client/ui-chat/src/client/chat/turn-rail-items.ts +++ b/packages/client/ui-chat/src/client/chat/turn-rail-items.ts @@ -24,17 +24,22 @@ export interface TurnRailItem { const EMPTY_ITEMS: readonly TurnRailItem[] = [] -/** Structurally narrow one wire outline entry (projection values cross the wire). */ +/** + * Structurally narrow one wire outline entry (projection values cross the + * wire). `turn` and `seq` are the load-bearing fields — a mark cannot exist + * or jump without them — so their damage drops the entry; the previews are + * decorative, so a malformed one degrades to `''` and the turn stays + * navigable by number. + */ function outlineEntry(value: unknown): { turn: number; seq: number; prompt: string; response: string } | undefined { if (typeof value !== 'object' || value === null) return undefined const entry = value as { turn?: unknown; seq?: unknown; prompt?: unknown; response?: unknown } if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return undefined if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0) return undefined - if (typeof entry.prompt !== 'string') return undefined return { turn: entry.turn, seq: entry.seq, - prompt: entry.prompt, + prompt: typeof entry.prompt === 'string' ? entry.prompt : '', response: typeof entry.response === 'string' ? entry.response : '', } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts index ac6b51e323..8687c7afa4 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -20,7 +20,16 @@ function preview(parts: Iterable, limit: number): string { unread = true break } - text += text === '' ? part : ` ${part}` + // Per-part bound: this runs on every structural rail update, so one huge + // text block must not be concatenated (and regex-normalized) whole for a + // preview this short. + const clipped = part.length > limit * 2 + const chunk = clipped ? part.slice(0, limit * 2) : part + text += text === '' ? chunk : ` ${chunk}` + if (clipped) { + unread = true + break + } } const normalized = text.replace(/\s+/g, ' ').trim() if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…` diff --git a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts index 598bc198b2..8f9dee2b68 100644 --- a/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts +++ b/packages/client/ui-chat/tests/turn-rail-items.client.spec.ts @@ -55,19 +55,20 @@ describe('mergeTurnRailItems', () => { ]) }) - it('drops malformed wire entries and shapes without folding the rail', () => { + it('drops entries with damaged navigation fields but degrades malformed previews to empty', () => { expect(mergeTurnRailItems([loadedItem(1)], 'not an outline')).toEqual([ { turn: 1, prompt: 'p1', response: 'r1', anchor: { kind: 'loaded', key: 'anchor-1' } }, ]) const items = mergeTurnRailItems([], [ { turn: -1, seq: 0, prompt: 'negative turn', response: '' }, { turn: 2, seq: 0.5, prompt: 'fractional seq', response: '' }, - { turn: 3, seq: 4, prompt: 5, response: '' }, + { turn: 3, seq: 4, prompt: 5, response: 6 }, { turn: 6, seq: 7, prompt: 'kept', response: 8 }, null, ]) - // A non-string response degrades to '' while the entry itself survives. + // turn/seq are load-bearing (drop); previews are decorative (degrade). expect(items).toEqual([ + { turn: 3, prompt: '', response: '', anchor: { kind: 'unloaded', seq: 4 } }, { turn: 6, prompt: 'kept', response: '', anchor: { kind: 'unloaded', seq: 7 } }, ]) }) diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 7184bf5d17..68ae2bbf0c 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -42,7 +42,16 @@ function preview(content: MessageContent, limit: number): string { unread = true break } - text += text === '' ? block.text : ` ${block.text}` + // Per-block bound: the fold runs on every message event, so a single + // multi-megabyte block must not be concatenated (and regex-normalized) + // whole for a preview this short. + const clipped = block.text.length > limit * 2 + const chunk = clipped ? block.text.slice(0, limit * 2) : block.text + text += text === '' ? chunk : ` ${chunk}` + if (clipped) { + unread = true + break + } } const normalized = text.replace(/\s+/g, ' ').trim() if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…` diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index 3749fe554c..eb22ea571d 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -90,6 +90,22 @@ describe('turn outline projection unit', () => { expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled') }) + it('reads a bounded slice of one oversized text block instead of the whole payload', async () => { + const { ctx, session } = await harness(true) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `giant ${'g'.repeat(500_000)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + appendAssistant(session, 1, 1, `answer ${'a'.repeat(500_000)}`) + endTurn(session, 1) + const entry = outlineOf(ctx, session)[0] + expect(entry?.prompt).toMatch(/^giant g+…$/) + expect(entry?.prompt).toHaveLength(50) + expect(entry?.response).toMatch(/^answer a+…$/) + expect(entry?.response).toHaveLength(120) + }) + it('collapses whitespace and caps previews at their card budgets with an ellipsis', async () => { const { ctx, session } = await harness(true) session.append('turn/start', { turn: 1 }) From e71db15d1afcf694b11abece0cba0f3fd5eebb7e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 01:07:42 +0800 Subject: [PATCH 35/43] chore(release): align session-turn-outline with root 0.1.2-alpha.2, mark mirrored preview clone The dsh 0.1.2-alpha.2 release bumped every workspace after this branch forked, so the new package failed the workspace version constraint in the merge tree. The bounded preview() also grew identical enough to its deliberate host/client mirror to trip clone detection; the client copy now carries a jscpd ignore region naming the wire-boundary rationale. --- .../ui-chat/src/client/conversation-nodes/turn-navigation.ts | 4 ++++ packages/session/session-turn-outline/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts index 8687c7afa4..331840e80c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-navigation.ts @@ -12,6 +12,9 @@ const PROMPT_PREVIEW_LIMIT = 50 const RESPONSE_PREVIEW_LIMIT = 120 /** Join rendered text, collapse whitespace, and cap at `limit` with a trailing ellipsis when clipped. */ +// Deliberate mirror of the turnOutline projection's preview(): the wire +// boundary forbids sharing code with the host package. +/* jscpd:ignore-start */ function preview(parts: Iterable, limit: number): string { let text = '' let unread = false @@ -35,6 +38,7 @@ function preview(parts: Iterable, limit: number): string { if (normalized.length > limit - 1) return `${normalized.slice(0, limit - 1).trimEnd()}…` return unread ? `${normalized}…` : normalized } +/* jscpd:ignore-end */ function promptText(node: ChatNode): string { if (node.kind !== 'user') return '' diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index 7c12ba5162..c1be737fc0 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.2-alpha.1", + "version": "0.1.2-alpha.2", "publishConfig": { "access": "public" }, From 9069a8b6f8cd46747654e3e7959a3bfaf038179d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 14:11:37 +0800 Subject: [PATCH 36/43] refactor(session-projection): compare per-step views instead of storing a dedup baseline Review suggestion (imccyu): the drive holds both the previous and next state, so the identity gate can compute view(previous) and view(next) in the driving step and compare them directly. The stored lastView cell field and its stamp-on-every-change rule are deleted; with no dedup memory, nothing can go stale across listener generations by construction, and a rebuilt cell no longer pushes an unchanged view on its first live event. Costs one extra pure view() call per changed state. --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +-- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +-- docs/subsystems/session-projection.md | 4 +-- docs/subsystems/session-projection.zh.md | 4 +-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection/README.i18n.yaml | 4 +-- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 35 +++++++------------ .../session-projection/tests/registry.spec.ts | 12 ++++--- 12 files changed, 35 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index dc48dfa503..1d118bca77 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: b2803aebf54c342c08da92131db7617b28ad1620 -2026-08-30-web-turn-rail-outline-jump.zh.md: 547e708a76e2205f7e2a1c83c9b3330975136393 +2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e +2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index b2803aebf5..ca59263d10 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output against the unit's previous projection and stays quiet when `Object.is`-identical; the baseline advances on every change, heard or not, so a later listener generation still sees every value transition. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index 547e708a76..ab5df81b85 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把原始 `view` 输出与该单元上一次投影值比较,`Object.is` 相同即保持安静;基线随每次变更前进、有无监听者都一样,后来的监听器换代仍能看到每次值转变。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 63a040c196..1a91aaf7c3 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: b7709b8d6254166afff08688c463161998731864 -session-projection.zh.md: 71da679e0cf50e6527889fd209a43291371ce313 +session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98 +session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index b7709b8d62..52a745b31c 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the unit's previous projection (the baseline advances with every change, heard or not), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 71da679e0c..044351647a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与该单元上一次投影值 `Object.is` 相同(基线随每次变更前进,无论有无监听者),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a55f3bf514..20926bcafe 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; the baseline advances with every change, heard or not). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 7a6652ec0e..3fe3d649a0 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: c498d6f163d704467f035f07e3da1ce1d484a163 -README.zh.md: eff47a64f2547e56642527cffb05e219d18397bf +README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362 +README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index c498d6f163..753b83c2f8 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the unit's previous projection stays quiet, with the baseline advancing on every change, heard or not (so a unit can buffer working fields behind an identity-stable projection without silencing later listener generations). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index eff47a64f2..dd036b4553 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与该单元上一次投影值相同的同样保持安静,且基线随每次变更前进、有无监听者都一样(单元因此可以把工作字段缓冲在身份稳定的投影之后,而不会静默掉后来监听者的值变化)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 3e3879df23..0af012beea 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -144,14 +144,6 @@ interface UnitCell { state: unknown /** Seq of the last event passed through `apply` (regardless of change). */ observedSeq: number - /** - * Raw (pre-validation) `view` output of the last changed state, stamped - * whether or not a listener heard it. A changed state whose raw view is - * `Object.is` to this stays quiet, so a unit can buffer working fields in - * state by keeping its wire projection identity-stable — and a listener - * subscribing later still sees every value transition since this baseline. - */ - lastView?: { raw: unknown } } /** @@ -179,7 +171,8 @@ interface Registration { * reference in a client-visible unit notifies the change feed with the * schema-validated view — unless the raw view output is `Object.is`-identical * to the unit's previous projection (identity-stable projections stay quiet; - * the baseline advances with every change, heard or not). + * both views are computed from the states in hand each step, so no stored + * comparison value exists to go stale across listener generations). * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -637,21 +630,19 @@ export class SessionProjectionRegistry extends Service { } else { this.advanceCell(registration.def, cell, session.events, event.seq - 1) } - const next = registration.def.apply(cell.state, event) - const changed = !Object.is(next, cell.state) + const previous = cell.state + const next = registration.def.apply(previous, event) + const changed = !Object.is(next, previous) cell.state = next cell.observedSeq = event.seq - if (changed && registration.def.wire !== undefined) { - // Identity gate on the raw view: a changed state whose projection is - // reference-identical to the previous one stays quiet, so a unit can - // buffer working fields without spamming the feed. The baseline is - // stamped on every change — listeners or none — so a later listener - // generation cannot be silenced by a value the unobserved state - // passed through and returned to. - const raw = registration.def.wire.view(cell.state) - const identical = cell.lastView !== undefined && Object.is(cell.lastView.raw, raw) - cell.lastView = { raw } - if (identical || this.listeners.size === 0) continue + if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { + // Identity gate on the raw view, computed from the two states in + // hand: a changed state whose projection is reference-identical to + // the previous state's stays quiet, so a unit can buffer working + // fields without spamming the feed. Nothing is stored, so no + // comparison value exists to go stale across listener generations. + const raw = registration.def.wire.view(next) + if (Object.is(registration.def.wire.view(previous), raw)) continue const value = registration.def.wire.viewSchema.parse(raw) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 63d28e68fe..5ab26d4458 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -151,11 +151,12 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) }) - it('advances the dedup baseline while unobserved, so a later listener hears a return to an old value', async () => { + it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => { const { ctx, session } = await harness() // A primitive-valued view compares by value under Object.is (the title - // unit's shape), which is exactly where a stale baseline could silence a - // real transition. + // unit's shape), which is exactly where remembering a delivered value — + // instead of comparing the two states in hand — would silence a real + // transition. ctx.sessionProjections.register({ key: 'test/label', stateSchema: z.string(), @@ -172,8 +173,9 @@ describe('SessionProjectionRegistry drive', () => { stop() // Unobserved transition away from 'A'… mark(session, ['B']) - // …then a new listener generation subscribes and the value returns: with - // a baseline frozen at 'A' this delivery would be silenced. + // …then a new listener generation subscribes and the value returns: + // dedup memory frozen at the delivered 'A' would silence this delivery; + // the per-step previous-state comparison sees 'B' → 'A' and fires. const second: string[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { if (key === 'test/label') second.push(value as string) From acc23f6d9c34eedc24bad3c14c70fefd04b8bf57 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 14:43:03 +0800 Subject: [PATCH 37/43] feat(session-projection): unit-declared viewKey change token Review follow-up (imccyu): comparing view(previous) recomputes the view on every quiet change. The wire block now takes an optional viewKey(state) declaring the cheap comparison token; the drive compares tokens across the previous and next states and calls view only for an actual push. Default stays the raw view output. turnOutline declares viewKey: state => state.turns, making the identity-stable-turns convention an explicit contract; the registration erasure forwards viewKey (dropping it silently reverted the gate to the fallback, caught by the new view-call-counting test). --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 4 +- docs/subsystems/session-projection.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 52 ++++++++++++++----- .../session-projection/tests/registry.spec.ts | 43 +++++++++++++++ .../session-turn-outline/src/projection.ts | 3 ++ .../tests/projection.spec.ts | 5 ++ 14 files changed, 105 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index 1d118bca77..502a325a8f 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e -2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d +2026-08-30-web-turn-rail-outline-jump.md: 0c14be30a61f277b05ad3da2169cae5924f89f0b +2026-08-30-web-turn-rail-outline-jump.zh.md: 707d7e5e8c76bf1fb891628fc0730c72f5ce7612 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index ca59263d10..0c14be30a6 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now compares the unit's `viewKey` token (default: the raw `view` output) across the previous and next states and stays quiet when `Object.is`-identical; both tokens are computed in the driving step (review moved this off a stored last-delivered value, then onto a declared cheap token), so no dedup memory exists to go stale and `view` runs only for an actual push. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index ab5df81b85..707d7e5e8c 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在会把单元声明的 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相互比较,`Object.is` 相同即保持安静;两侧令牌都在驱动当步现算(评审先把「存上一次交付值」改为现算、再落到声明式廉价令牌),不存去重记忆,`view` 只在真推送时运行。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 1a91aaf7c3..714becf73d 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98 -session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0 +session-projection.md: b241c7dbceab23850e9a3755076ee23f83d1ecda +session-projection.zh.md: 53cb8c251f54ee0343eccab57533d9a0d91f993a diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 52a745b31c..b241c7dbce 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the unit's `viewKey` token (default: the raw `view` output) is `Object.is`-identical across the previous and next states (both tokens are computed in the driving step — no stored comparison value exists to go stale, and a declared `viewKey` keeps `view` off the quiet path), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 044351647a..53cb8c251f 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上 `Object.is` 相同(两侧令牌都在驱动当步现算,不存比较值故无从过期;声明了 `viewKey` 时安静路径完全不跑 `view`),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 20926bcafe..e0548165b6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit\'s `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', @@ -4593,7 +4593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n viewKey?(state: NoInfer): unknown;\n } : never;\n stateVersion: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 3fe3d649a0..a6dbc00807 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362 -README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be +README.md: 2e4a5f7897c62e18899f92e3aa04d9ccff51b161 +README.zh.md: 7b02d534cb8dc7c6150084b7aa140200210d7b71 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 753b83c2f8..2e4a5f7897 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose `viewKey` token (default: the raw `view` output) is identical across the previous and next states stays quiet — both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and a declared `viewKey` keeps `view` itself off the quiet path (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index dd036b4553..7b02d534cb 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相同的同样保持安静——两侧令牌都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期;声明了 `viewKey` 的单元在安静路径上完全不跑 `view`(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 0af012beea..7053fbaf13 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -72,6 +72,18 @@ export interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] + /** + * Change-detection token for the served view: after a changed `apply`, + * the feed compares this token across the previous and next states with + * `Object.is` and stays quiet when identical, so `view` runs only for an + * actual push. Must be a cheap pure read (a state field, not a + * computation). Omitted, the raw `view` output itself is the token — + * correct for identity-stable views, but then `view` runs per changed + * state, and views building fresh objects per call push on every change. + * @param state - a state on either side of the comparison. + * @returns the token deciding whether the served view changed. + */ + viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -135,7 +147,11 @@ interface ErasedDefinition { stateSchema: { parse(value: unknown): unknown } init(header: SessionHeader): unknown apply(state: unknown, event: SessionEvent): unknown - wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined + wire: { + viewSchema: { parse(value: unknown): unknown } + view(state: unknown): unknown + viewKey?(state: unknown): unknown + } | undefined stateVersion: number } @@ -169,10 +185,11 @@ interface Registration { * service subscribes to `session/event` once; every committed event passes * every registered unit's `apply` (eager drive), and a changed state * reference in a client-visible unit notifies the change feed with the - * schema-validated view — unless the raw view output is `Object.is`-identical - * to the unit's previous projection (identity-stable projections stay quiet; - * both views are computed from the states in hand each step, so no stored - * comparison value exists to go stale across listener generations). + * schema-validated view — unless the unit's `viewKey` token (default: the raw + * view output) is `Object.is`-identical across the previous and next states. + * Both tokens are computed from the states in hand each step, so no stored + * comparison value exists to go stale across listener generations, and + * `view` runs only for an actual push when a `viewKey` is declared. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -244,7 +261,9 @@ export class SessionProjectionRegistry extends Service { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown + viewKey?(state: S): unknown } | undefined + const viewKey = wire?.viewKey const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, @@ -252,7 +271,11 @@ export class SessionProjectionRegistry extends Service { apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined - : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, + : { + viewSchema: wire.viewSchema, + view: state => wire.view(state as S), + ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }), + }, stateVersion: definition.stateVersion, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { @@ -636,14 +659,15 @@ export class SessionProjectionRegistry extends Service { cell.state = next cell.observedSeq = event.seq if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { - // Identity gate on the raw view, computed from the two states in - // hand: a changed state whose projection is reference-identical to - // the previous state's stays quiet, so a unit can buffer working - // fields without spamming the feed. Nothing is stored, so no - // comparison value exists to go stale across listener generations. - const raw = registration.def.wire.view(next) - if (Object.is(registration.def.wire.view(previous), raw)) continue - const value = registration.def.wire.viewSchema.parse(raw) + // Identity gate on the unit's comparison token, computed from the + // two states in hand: identical tokens mean the served view did not + // change, so a unit can buffer working fields without spamming the + // feed and `view` runs only for an actual push. Nothing is stored, + // so no comparison value exists to go stale across listener + // generations. + const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view + if (Object.is(keyOf(previous), keyOf(next))) continue + const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next)) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 5ab26d4458..585853d2a5 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -151,6 +151,49 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) }) + it('compares by the declared viewKey token and runs view only for a push', async () => { + const { ctx, session } = await harness() + let viewCalls = 0 + // A fresh-array view would push on every changed state under the default + // raw-view token; the declared token pins dedup to `marks` identity and + // keeps `view` off the quiet path entirely. + ctx.sessionProjections.register({ + key: 'test/buffered', + stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }), + init: () => ({ marks: [], draft: '' }), + apply: (state, event) => { + if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' } + if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` } + return state + }, + wire: { + viewSchema: z.array(z.string()), + view: (state) => { + viewCalls += 1 + return [...state.marks] + }, + viewKey: state => state.marks, + }, + stateVersion: 1, + }) + const seen: { value: unknown; seq: number }[] = [] + ctx.sessionProjections.onChanged((_session, key, value, seq) => { + if (key === 'test/buffered') seen.push({ value, seq }) + }) + const first = mark(session, ['a']) + expect(viewCalls).toBe(1) + // Draft-only apply: state reference moves, the token does not — and the + // fresh-object view is never consulted. + session.append('turn/start', { turn: 1 }) + expect(viewCalls).toBe(1) + const second = mark(session, ['a', 'b']) + expect(seen).toEqual([ + { value: ['a'], seq: first.seq }, + { value: ['a', 'b'], seq: second.seq }, + ]) + expect(viewCalls).toBe(2) + }) + it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => { const { ctx, session } = await harness() // A primitive-valued view compares by value under Object.is (the title diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 68ae2bbf0c..811b699430 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -133,5 +133,8 @@ export const turnOutlineProjectionDefinition = { wire: { viewSchema: turnOutlineEntriesSchema, view: state => state.turns, + // Draft-only applies replace the state object but reuse `turns`, so this + // token keeps the feed quiet until a turn-level commit. + viewKey: state => state.turns, }, } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState> diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index eb22ea571d..2006290d2b 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -262,4 +262,9 @@ describe('turn outline projection unit', () => { }, }, [], 0, session.header)).not.toThrow() }) + + it('keys change detection to the turns array identity, so draft-only applies stay quiet without a view call', () => { + const state = { turns: [{ turn: 1, seq: 1, prompt: 'p', response: '' }], draft: 'buffering' } + expect(turnOutlineProjectionDefinition.wire.viewKey(state)).toBe(state.turns) + }) }) From 9836fdbbd7e5124ea5878c9adcf8110de8f5c9a2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 14:44:16 +0800 Subject: [PATCH 38/43] docs(session-projection): describe the change feed by its viewKey token --- packages/session/session-projection/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 7053fbaf13..4df77d911a 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -98,9 +98,9 @@ export interface ProjectionDefinition< * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * changed state whose `viewKey` token (default: the raw `view` output) is + * `Object.is`-identical to the previous state's does not fire, so a unit can + * buffer working fields in state behind an identity-stable projection. */ export type ProjectionChangeListener = ( session: Session, From f2e4078d8c25c40c1e7dc6190845c887a2e6b6a5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 14:46:29 +0800 Subject: [PATCH 39/43] docs(session-projection): carry viewKey through the subsystem type fences --- docs/subsystems/session-projection.i18n.yaml | 4 ++-- docs/subsystems/session-projection.md | 18 +++++++++++++++--- docs/subsystems/session-projection.zh.md | 18 +++++++++++++++--- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 714becf73d..bb2b34d2c8 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: b241c7dbceab23850e9a3755076ee23f83d1ecda -session-projection.zh.md: 53cb8c251f54ee0343eccab57533d9a0d91f993a +session-projection.md: 5943281952def6e86f819e73c7822dc7f6ed4ed6 +session-projection.zh.md: 99009a0472ed40f418527848e91523d0ff0e2303 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index b241c7dbce..5943281952 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -52,6 +52,18 @@ interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] + /** + * Change-detection token for the served view: after a changed `apply`, + * the feed compares this token across the previous and next states with + * `Object.is` and stays quiet when identical, so `view` runs only for an + * actual push. Must be a cheap pure read (a state field, not a + * computation). Omitted, the raw `view` output itself is the token — + * correct for identity-stable views, but then `view` runs per changed + * state, and views building fresh objects per call push on every change. + * @param state - a state on either side of the comparison. + * @returns the token deciding whether the served view changed. + */ + viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -86,9 +98,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * changed state whose `viewKey` token (default: the raw `view` output) is + * `Object.is`-identical to the previous state's does not fire, so a unit can + * buffer working fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 53cb8c251f..99009a0472 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -52,6 +52,18 @@ interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] + /** + * Change-detection token for the served view: after a changed `apply`, + * the feed compares this token across the previous and next states with + * `Object.is` and stays quiet when identical, so `view` runs only for an + * actual push. Must be a cheap pure read (a state field, not a + * computation). Omitted, the raw `view` output itself is the token — + * correct for identity-stable views, but then `view` runs per changed + * state, and views building fresh objects per call push on every change. + * @param state - a state on either side of the comparison. + * @returns the token deciding whether the served view changed. + */ + viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -86,9 +98,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * changed state whose `viewKey` token (default: the raw `view` output) is + * `Object.is`-identical to the previous state's does not fire, so a unit can + * buffer working fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, From a2437db180c9afa15778924f5f024f60aaf158cd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 15:44:14 +0800 Subject: [PATCH 40/43] revert(session-projection): drop the viewKey token, keep the per-step raw-view gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review consensus: for the only declaring unit the token function was literally the view function, so the extra wire member bought nothing — default raw-view comparison produces the identical Object.is on state.turns at the identical cost (one property read per side). The per-step gate stays: it holds the no-stored-baseline property, and its measured overhead (~ns per changed state) is four orders of magnitude below the push path it guards. --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 22 ++----- docs/subsystems/session-projection.zh.md | 22 ++----- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 58 ++++++------------- .../session-projection/tests/registry.spec.ts | 43 -------------- .../session-turn-outline/src/projection.ts | 3 - .../tests/projection.spec.ts | 5 -- 14 files changed, 39 insertions(+), 138 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index 502a325a8f..1d118bca77 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: 0c14be30a61f277b05ad3da2169cae5924f89f0b -2026-08-30-web-turn-rail-outline-jump.zh.md: 707d7e5e8c76bf1fb891628fc0730c72f5ce7612 +2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e +2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index 0c14be30a6..ca59263d10 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now compares the unit's `viewKey` token (default: the raw `view` output) across the previous and next states and stays quiet when `Object.is`-identical; both tokens are computed in the driving step (review moved this off a stored last-delivered value, then onto a declared cheap token), so no dedup memory exists to go stale and `view` runs only for an actual push. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index 707d7e5e8c..ab5df81b85 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在会把单元声明的 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相互比较,`Object.is` 相同即保持安静;两侧令牌都在驱动当步现算(评审先把「存上一次交付值」改为现算、再落到声明式廉价令牌),不存去重记忆,`view` 只在真推送时运行。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index bb2b34d2c8..1a91aaf7c3 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: 5943281952def6e86f819e73c7822dc7f6ed4ed6 -session-projection.zh.md: 99009a0472ed40f418527848e91523d0ff0e2303 +session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98 +session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 5943281952..52a745b31c 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -52,18 +52,6 @@ interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] - /** - * Change-detection token for the served view: after a changed `apply`, - * the feed compares this token across the previous and next states with - * `Object.is` and stays quiet when identical, so `view` runs only for an - * actual push. Must be a cheap pure read (a state field, not a - * computation). Omitted, the raw `view` output itself is the token — - * correct for identity-stable views, but then `view` runs per changed - * state, and views building fresh objects per call push on every change. - * @param state - a state on either side of the comparison. - * @returns the token deciding whether the served view changed. - */ - viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -98,9 +86,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose `viewKey` token (default: the raw `view` output) is - * `Object.is`-identical to the previous state's does not fire, so a unit can - * buffer working fields in state behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, @@ -110,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the unit's `viewKey` token (default: the raw `view` output) is `Object.is`-identical across the previous and next states (both tokens are computed in the driving step — no stored comparison value exists to go stale, and a declared `viewKey` keeps `view` off the quiet path), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 99009a0472..044351647a 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -52,18 +52,6 @@ interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] - /** - * Change-detection token for the served view: after a changed `apply`, - * the feed compares this token across the previous and next states with - * `Object.is` and stays quiet when identical, so `view` runs only for an - * actual push. Must be a cheap pure read (a state field, not a - * computation). Omitted, the raw `view` output itself is the token — - * correct for identity-stable views, but then `view` runs per changed - * state, and views building fresh objects per call push on every change. - * @param state - a state on either side of the comparison. - * @returns the token deciding whether the served view changed. - */ - viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -98,9 +86,9 @@ interface ProjectionSnapshot { * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose `viewKey` token (default: the raw `view` output) is - * `Object.is`-identical to the previous state's does not fire, so a unit can - * buffer working fields in state behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ type ProjectionChangeListener = ( session: Session, @@ -110,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上 `Object.is` 相同(两侧令牌都在驱动当步现算,不存比较值故无从过期;声明了 `viewKey` 时安静路径完全不跑 `view`),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e0548165b6..20926bcafe 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the unit\'s `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', @@ -4593,7 +4593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionDefinition', - declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n viewKey?(state: NoInfer): unknown;\n } : never;\n stateVersion: number;\n}', + declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index a6dbc00807..3fe3d649a0 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 2e4a5f7897c62e18899f92e3aa04d9ccff51b161 -README.zh.md: 7b02d534cb8dc7c6150084b7aa140200210d7b71 +README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362 +README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2e4a5f7897..753b83c2f8 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose `viewKey` token (default: the raw `view` output) is identical across the previous and next states stays quiet — both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and a declared `viewKey` keeps `view` itself off the quiet path (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 7b02d534cb..dd036b4553 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相同的同样保持安静——两侧令牌都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期;声明了 `viewKey` 的单元在安静路径上完全不跑 `view`(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 4df77d911a..0af012beea 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -72,18 +72,6 @@ export interface ProjectionDefinition< * @returns the whole current value for this unit's key. */ view(state: NoInfer): SessionProjectionMap[K] - /** - * Change-detection token for the served view: after a changed `apply`, - * the feed compares this token across the previous and next states with - * `Object.is` and stays quiet when identical, so `view` runs only for an - * actual push. Must be a cheap pure read (a state field, not a - * computation). Omitted, the raw `view` output itself is the token — - * correct for identity-stable views, but then `view` runs per changed - * state, and views building fresh objects per call push on every change. - * @param state - a state on either side of the comparison. - * @returns the token deciding whether the served view changed. - */ - viewKey?(state: NoInfer): unknown } : never /** * Persisted-cache invalidation version: bump whenever the serialized state fields or the @@ -98,9 +86,9 @@ export interface ProjectionDefinition< * Change-feed listener: one unit's served value changed for one session. * `value` is the schema-validated `view` output; `seq` is the unit's * watermark at emission (the seq of the event that caused the change). A - * changed state whose `viewKey` token (default: the raw `view` output) is - * `Object.is`-identical to the previous state's does not fire, so a unit can - * buffer working fields in state behind an identity-stable projection. + * changed state whose raw `view` output is `Object.is`-identical to the + * unit's previous projection does not fire, so a unit can buffer working + * fields in state behind an identity-stable projection. */ export type ProjectionChangeListener = ( session: Session, @@ -147,11 +135,7 @@ interface ErasedDefinition { stateSchema: { parse(value: unknown): unknown } init(header: SessionHeader): unknown apply(state: unknown, event: SessionEvent): unknown - wire: { - viewSchema: { parse(value: unknown): unknown } - view(state: unknown): unknown - viewKey?(state: unknown): unknown - } | undefined + wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined stateVersion: number } @@ -185,11 +169,10 @@ interface Registration { * service subscribes to `session/event` once; every committed event passes * every registered unit's `apply` (eager drive), and a changed state * reference in a client-visible unit notifies the change feed with the - * schema-validated view — unless the unit's `viewKey` token (default: the raw - * view output) is `Object.is`-identical across the previous and next states. - * Both tokens are computed from the states in hand each step, so no stored - * comparison value exists to go stale across listener generations, and - * `view` runs only for an actual push when a `viewKey` is declared. + * schema-validated view — unless the raw view output is `Object.is`-identical + * to the unit's previous projection (identity-stable projections stay quiet; + * both views are computed from the states in hand each step, so no stored + * comparison value exists to go stale across listener generations). * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -261,9 +244,7 @@ export class SessionProjectionRegistry extends Service { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown - viewKey?(state: S): unknown } | undefined - const viewKey = wire?.viewKey const erased: ErasedDefinition = { key: definition.key, stateSchema: definition.stateSchema, @@ -271,11 +252,7 @@ export class SessionProjectionRegistry extends Service { apply: (state, event) => definition.apply(state as S, event), wire: wire === undefined ? undefined - : { - viewSchema: wire.viewSchema, - view: state => wire.view(state as S), - ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }), - }, + : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) }, stateVersion: definition.stateVersion, } if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { @@ -659,15 +636,14 @@ export class SessionProjectionRegistry extends Service { cell.state = next cell.observedSeq = event.seq if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { - // Identity gate on the unit's comparison token, computed from the - // two states in hand: identical tokens mean the served view did not - // change, so a unit can buffer working fields without spamming the - // feed and `view` runs only for an actual push. Nothing is stored, - // so no comparison value exists to go stale across listener - // generations. - const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view - if (Object.is(keyOf(previous), keyOf(next))) continue - const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next)) + // Identity gate on the raw view, computed from the two states in + // hand: a changed state whose projection is reference-identical to + // the previous state's stays quiet, so a unit can buffer working + // fields without spamming the feed. Nothing is stored, so no + // comparison value exists to go stale across listener generations. + const raw = registration.def.wire.view(next) + if (Object.is(registration.def.wire.view(previous), raw)) continue + const value = registration.def.wire.viewSchema.parse(raw) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 585853d2a5..5ab26d4458 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -151,49 +151,6 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) }) - it('compares by the declared viewKey token and runs view only for a push', async () => { - const { ctx, session } = await harness() - let viewCalls = 0 - // A fresh-array view would push on every changed state under the default - // raw-view token; the declared token pins dedup to `marks` identity and - // keeps `view` off the quiet path entirely. - ctx.sessionProjections.register({ - key: 'test/buffered', - stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }), - init: () => ({ marks: [], draft: '' }), - apply: (state, event) => { - if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' } - if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` } - return state - }, - wire: { - viewSchema: z.array(z.string()), - view: (state) => { - viewCalls += 1 - return [...state.marks] - }, - viewKey: state => state.marks, - }, - stateVersion: 1, - }) - const seen: { value: unknown; seq: number }[] = [] - ctx.sessionProjections.onChanged((_session, key, value, seq) => { - if (key === 'test/buffered') seen.push({ value, seq }) - }) - const first = mark(session, ['a']) - expect(viewCalls).toBe(1) - // Draft-only apply: state reference moves, the token does not — and the - // fresh-object view is never consulted. - session.append('turn/start', { turn: 1 }) - expect(viewCalls).toBe(1) - const second = mark(session, ['a', 'b']) - expect(seen).toEqual([ - { value: ['a'], seq: first.seq }, - { value: ['a', 'b'], seq: second.seq }, - ]) - expect(viewCalls).toBe(2) - }) - it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => { const { ctx, session } = await harness() // A primitive-valued view compares by value under Object.is (the title diff --git a/packages/session/session-turn-outline/src/projection.ts b/packages/session/session-turn-outline/src/projection.ts index 811b699430..68ae2bbf0c 100644 --- a/packages/session/session-turn-outline/src/projection.ts +++ b/packages/session/session-turn-outline/src/projection.ts @@ -133,8 +133,5 @@ export const turnOutlineProjectionDefinition = { wire: { viewSchema: turnOutlineEntriesSchema, view: state => state.turns, - // Draft-only applies replace the state object but reuse `turns`, so this - // token keeps the feed quiet until a turn-level commit. - viewKey: state => state.turns, }, } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState> diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index 2006290d2b..eb22ea571d 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -262,9 +262,4 @@ describe('turn outline projection unit', () => { }, }, [], 0, session.header)).not.toThrow() }) - - it('keys change detection to the turns array identity, so draft-only applies stay quiet without a view call', () => { - const state = { turns: [{ turn: 1, seq: 1, prompt: 'p', response: '' }], draft: 'buffering' } - expect(turnOutlineProjectionDefinition.wire.viewKey(state)).toBe(state.turns) - }) }) From 12bef3b577c6ce2b44651bf0fc28db282fc12fb9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 16:05:56 +0800 Subject: [PATCH 41/43] perf(session-projection): memoize raw views by state identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the per-step gate recomputed view(previous) on every changed apply — a property read for identity-stable views, but a fresh throwaway object per change for computing views. The registry now keeps a WeakMap from state object to raw view: the previous state's view was cached when that state was current, so each distinct state's view computes exactly once (gate and snapshot share the memo) and the quiet path allocates nothing. Unlike the earlier lastView record, an entry is keyed by the state itself — the view of that exact state by the pure-view contract — so no stamping discipline exists to get wrong. Primitive states bypass the WeakMap and compute directly. --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 4 +- docs/subsystems/session-projection.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 +- .../session/session-projection/README.zh.md | 2 +- .../session/session-projection/src/index.ts | 47 ++++++++++++++----- .../session-projection/tests/registry.spec.ts | 39 +++++++++++++++ 12 files changed, 89 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index 1d118bca77..36a5d28b87 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e -2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d +2026-08-30-web-turn-rail-outline-jump.md: c1f190832ebfb83200d8f7cb5c01b9a45167ccab +2026-08-30-web-turn-rail-outline-jump.zh.md: 66196430ce095e822a5e05863f591ea5cebce570 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index ca59263d10..c1f190832e 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; views are memoized by state object identity (review moved this off a stored last-delivered value, then off per-step recomputation), so each distinct state's view computes once and the memo — keyed by the state itself, not by what the feed delivered — cannot go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index ab5df81b85..66196430ce 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;视图按状态对象身份做备忘(评审先从「存上一次交付值」改为现算、再改为按状态键缓存),每个不同状态只算一次,备忘的键是状态本身而非交付历史,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 1a91aaf7c3..0f80f31420 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98 -session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0 +session-projection.md: 2a08f1a7131094b6049ecd9f5d43df448bc6dbf3 +session-projection.zh.md: 73c1f02b897f5ac5408e3199096876d3ba438224 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 52a745b31c..2a08f1a713 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (views are memoized by state object identity — one computation per distinct state, and no last-delivered record exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. ## The registry: `ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 044351647a..73c1f02b89 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(视图按状态对象身份做备忘——每个不同状态只算一次,且不存在会过期的「上次交付」记录),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 ## 注册表:`ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 20926bcafe..0df4db8791 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state\'s view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 3fe3d649a0..571c63775f 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362 -README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be +README.md: 4ea86cb187f1081978be97ec0094977ef5e6778e +README.zh.md: ab051795bdcb0547e817063bd91374954d79cb0d diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 753b83c2f8..4ea86cb187 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index dd036b4553..ab051795bd 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——视图按状态对象身份做备忘,每个不同状态的视图只计算一次,且不存在会在监听器换代期间过期的「上次交付」记录(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 0af012beea..27e2e389cc 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -160,6 +160,13 @@ interface UnitCell { interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap + /** + * Raw `view` output per state object (pure-view memo). An entry is the + * view of that exact state — not a last-delivered record — so it cannot go + * stale; a missing entry recomputes. Weak keys die with their states; + * primitive states bypass the memo. + */ + readonly viewMemo: WeakMap /** Live registrants sharing this unit; the last one out removes the key. */ refs: number } @@ -170,9 +177,10 @@ interface Registration { * every registered unit's `apply` (eager drive), and a changed state * reference in a client-visible unit notifies the change feed with the * schema-validated view — unless the raw view output is `Object.is`-identical - * to the unit's previous projection (identity-stable projections stay quiet; - * both views are computed from the states in hand each step, so no stored - * comparison value exists to go stale across listener generations). + * to the unit's previous projection (identity-stable projections stay quiet). + * Views are memoized by state object identity, so each distinct state's view + * computes once and no last-delivered record exists to go stale across + * listener generations. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -262,7 +270,7 @@ export class SessionProjectionRegistry extends Service { const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + this.registrations.set(key, { def: erased, cells: new WeakMap(), viewMemo: new WeakMap(), refs: 1 }) } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) @@ -636,13 +644,13 @@ export class SessionProjectionRegistry extends Service { cell.state = next cell.observedSeq = event.seq if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { - // Identity gate on the raw view, computed from the two states in - // hand: a changed state whose projection is reference-identical to - // the previous state's stays quiet, so a unit can buffer working - // fields without spamming the feed. Nothing is stored, so no - // comparison value exists to go stale across listener generations. - const raw = registration.def.wire.view(next) - if (Object.is(registration.def.wire.view(previous), raw)) continue + // Identity gate on the raw view, memoized by state identity: the + // previous state's view was cached when that state was current, so + // each distinct state's view computes once and the quiet path + // allocates nothing. The memo cannot go stale — an entry is the view + // of that exact state, not a record of what the feed last delivered. + const raw = this.viewOf(registration.def.wire, registration.viewMemo, next) + if (Object.is(this.viewOf(registration.def.wire, registration.viewMemo, previous), raw)) continue const value = registration.def.wire.viewSchema.parse(raw) for (const listener of this.listeners) { listener(session, registration.def.key as Extract, value, event.seq) @@ -655,7 +663,22 @@ export class SessionProjectionRegistry extends Service { private viewCell(registration: Registration, cell: UnitCell): unknown { const wire = registration.def.wire if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`) - return wire.viewSchema.parse(wire.view(cell.state)) + return wire.viewSchema.parse(this.viewOf(wire, registration.viewMemo, cell.state)) + } + + /** + * One unit's raw `view` output for one state, memoized by state object + * identity (the pure-view contract makes the entry permanently correct). + * Primitive states have no WeakMap key and compute directly. + * @param wire - the unit's wire block. + * @param memo - the unit's per-state view memo. + * @param state - a state produced by the unit's `init`/`apply`. + * @returns the raw (pre-validation) `view` output for that state. + */ + private viewOf(wire: NonNullable, memo: WeakMap, state: unknown): unknown { + if (typeof state !== 'object' || state === null) return wire.view(state) + if (!memo.has(state)) memo.set(state, wire.view(state)) + return memo.get(state) } } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 5ab26d4458..ef12ef6a14 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -151,6 +151,45 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) }) + it("computes each distinct state's view once: the memo serves previous states to the gate and snapshots", async () => { + const { ctx, session } = await harness() + let viewCalls = 0 + ctx.sessionProjections.register({ + key: 'test/buffered', + stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }), + init: () => ({ marks: [], draft: '' }), + apply: (state, event) => { + if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' } + if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` } + return state + }, + wire: { + viewSchema: z.array(z.string()), + view: (state) => { + viewCalls += 1 + return state.marks + }, + }, + stateVersion: 1, + }) + const seen: unknown[] = [] + ctx.sessionProjections.onChanged((_session, key, value) => { + if (key === 'test/buffered') seen.push(value) + }) + // First change touches two never-seen states (init and next): two calls. + mark(session, ['a']) + expect(viewCalls).toBe(2) + // Draft-only change: the new state computes, the previous is a memo hit. + session.append('turn/start', { turn: 1 }) + expect(viewCalls).toBe(3) + mark(session, ['a', 'b']) + expect(viewCalls).toBe(4) + expect(seen).toEqual([['a'], ['a', 'b']]) + // Snapshot reads reuse the same memo instead of recomputing the view. + expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) + expect(viewCalls).toBe(4) + }) + it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => { const { ctx, session } = await harness() // A primitive-valued view compares by value under Object.is (the title From 6f0daff1dd09c32305371ed2f66bae9a6fc96ae7 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 19:59:54 +0800 Subject: [PATCH 42/43] fix(session-projection): compare observed live views --- ...08-30-web-turn-rail-outline-jump.i18n.yaml | 4 +- .../2026-08-30-web-turn-rail-outline-jump.md | 2 +- ...026-08-30-web-turn-rail-outline-jump.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 20 +- docs/subsystems/session-projection.zh.md | 20 +- .../tests/session-projections.host.spec.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 8 +- .../session/session-projection/README.zh.md | 8 +- .../session/session-projection/src/index.ts | 97 ++++------ .../session-projection/src/invariant.ts | 4 +- .../session-projection/tests/registry.spec.ts | 176 ++++++++---------- 14 files changed, 160 insertions(+), 196 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml index 36a5d28b87..935dd280d4 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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-30-web-turn-rail-outline-jump.md -2026-08-30-web-turn-rail-outline-jump.md: c1f190832ebfb83200d8f7cb5c01b9a45167ccab -2026-08-30-web-turn-rail-outline-jump.zh.md: 66196430ce095e822a5e05863f591ea5cebce570 +2026-08-30-web-turn-rail-outline-jump.md: af2a464513d108429859c4f9ed8b4bfc58175e3c +2026-08-30-web-turn-rail-outline-jump.zh.md: b63b1c1f656689997b8a8266ee332dfdb47d4035 diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md index c1f190832e..af2a464513 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md @@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone. **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn. -**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; views are memoized by state object identity (review moved this off a stored last-delivered value, then off per-step recomputation), so each distinct state's view computes once and the memo — keyed by the state itself, not by what the feed delivered — cannot go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change. +**Change-feed identity gate (session-projection).** Each live unit cell keeps `[previousView, currentView]` raw outputs. When a state reference changes, the drive shifts current to previous; while a change listener exists it computes `view(nextState)` once, stores current, and emits only when the two outputs differ by `Object.is`. Without listeners, current becomes `undefined` without evaluating `view`, so the first later computed value publishes conservatively; catch-up folding invalidates current in the same way. This lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — is rejected because every quiet change would pay for full serialization. **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic. diff --git a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md index 66196430ce..b63b1c1f65 100644 --- a/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md +++ b/.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md @@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。 -**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;视图按状态对象身份做备忘(评审先从「存上一次交付值」改为现算、再改为按状态键缓存),每个不同状态只算一次,备忘的键是状态本身而非交付历史,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 +**变更流身份门(session-projection)。** 每个实时单元 cell 保存 `[previousView, currentView]` 原始输出。state 引用变化时,drive 先把 current 移到 previous;存在变更 listener 时只计算一次 `view(nextState)` 并写入 current,两个输出通过 `Object.is` 判定为不同时才发出通知。没有 listener 时不计算 `view`,而是把 current 写成 `undefined`,因此之后首次计算出的值会保守地发布;补折叠也以相同方式使 current 失效。这让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的 state 里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 0f80f31420..1cad3ee1f0 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.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/session-projection.md -session-projection.md: 2a08f1a7131094b6049ecd9f5d43df448bc6dbf3 -session-projection.zh.md: 73c1f02b897f5ac5408e3199096876d3ba438224 +session-projection.md: b5bacc4846a9a9709bb8c102aaa78810a2752110 +session-projection.zh.md: 36653721a125ceef13bff8b1a4562e7981a45696 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 2a08f1a713..b5bacc4846 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -47,7 +47,10 @@ interface ProjectionDefinition< /** Validates the wire payload before it leaves the host. */ viewSchema: ZodType /** - * State → wire payload (the read-side projection). + * State → wire payload (the read-side projection). The live drive keeps + * the two latest raw results and compares them with `Object.is`; an + * object-valued view must reuse its reference to suppress publication + * across internal-only state changes. * @param state - the current state. * @returns the whole current value for this unit's key. */ @@ -83,12 +86,9 @@ interface ProjectionSnapshot { ```ts type-equiv /** - * Change-feed listener: one unit's served value changed for one session. - * `value` is the schema-validated `view` output; `seq` is the unit's - * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * Change-feed listener: one unit's raw `view` result changed by `Object.is` + * for one session. `value` is the schema-validated output; `seq` is the + * unit's watermark at emission (the seq of the event that caused the change). */ type ProjectionChangeListener = ( session: Session, @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event — unless the raw `view` output is `Object.is`-identical to the previous state's (views are memoized by state object identity — one computation per distinct state, and no last-delivered record exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. ## The registry: `ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -204,7 +204,7 @@ register< K extends Exclude void diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 73c1f02b89..36653721a1 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -47,7 +47,10 @@ interface ProjectionDefinition< /** Validates the wire payload before it leaves the host. */ viewSchema: ZodType /** - * State → wire payload (the read-side projection). + * State → wire payload (the read-side projection). The live drive keeps + * the two latest raw results and compares them with `Object.is`; an + * object-valued view must reuse its reference to suppress publication + * across internal-only state changes. * @param state - the current state. * @returns the whole current value for this unit's key. */ @@ -83,12 +86,9 @@ interface ProjectionSnapshot { ```ts type-equiv /** - * Change-feed listener: one unit's served value changed for one session. - * `value` is the schema-validated `view` output; `seq` is the unit's - * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * Change-feed listener: one unit's raw `view` result changed by `Object.is` + * for one session. `value` is the schema-validated output; `seq` is the + * unit's watermark at emission (the seq of the event that caused the change). */ type ProjectionChangeListener = ( session: Session, @@ -98,7 +98,7 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(视图按状态对象身份做备忘——每个不同状态只算一次,且不存在会过期的「上次交付」记录),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 ## 注册表:`ctx.sessionProjections` @@ -180,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. ```ts cordis-catalog /** @@ -204,7 +204,7 @@ register< K extends Exclude void diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 8a31e9208a..0718f7d657 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -538,7 +538,7 @@ describe('Session control projection frames', () => { return frames } - it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => { + it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) const proxy = remote(ctx) @@ -554,6 +554,7 @@ describe('Session control projection frames', () => { now.mockReturnValue(200) session.append('turn/start', { turn: 1 }) now.mockReturnValue(300) + // The equal payload is a new object, so Object.is still treats its view as changed. seedMessages(session, 1) now.mockRestore() diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0df4db8791..c8c597a983 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1574,7 +1574,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet). Views are memoized by state object identity, so each distinct state\'s view computes once and no last-delivered record exists to go stale across listener generations. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', methods: [ { signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', @@ -1591,7 +1591,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'onChanged(listener: ProjectionChangeListener): () => void', description: 'Subscribe to the change feed. The registration is an effect on the calling context\'s fiber.', - parameters: [{ name: 'listener', description: 'called once per client-visible unit whose state reference changed, per committed event.' }], + parameters: [{ name: 'listener', description: 'called once per client-visible unit whose raw view changed by `Object.is`, per committed event.' }], returns: 'the exact disposer that unsubscribes.', }, { diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 571c63775f..5e8356ad31 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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-projection/README.md -README.md: 4ea86cb187f1081978be97ec0094977ef5e6778e -README.zh.md: ab051795bdcb0547e817063bd91374954d79cb0d +README.md: 2ef81c00b283e76d0553c85f1ce5acd12ab863da +README.zh.md: 996c51887689eed95a5cabee8cf89e6433575647 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 4ea86cb187..2ef81c00b2 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -51,7 +51,7 @@ const definition = { } ``` -`apply` must be synchronous and must return the same state reference for events that do not concern the unit — an unchanged reference means zero downstream work. A state-carrying log event must carry the complete post-change state, never a bare delta. +`apply` must be synchronous and must return the same state reference for events that do not concern the unit — an unchanged reference means zero downstream work. The registry compares consecutive raw `wire.view` results with `Object.is`; an object or array view must reuse its reference to suppress publication across internal-only state changes, while a structurally equal new object is still a change. A state-carrying log event must carry the complete post-change state, never a bare delta. ### Register and read @@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable ### Design concept -The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — views are memoized by state object identity, so each distinct state's view computes once and no last-delivered record exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. +The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The first `Object.is` gate skips view work when the state reference is unchanged; a two-slot live-drive cache reuses the previous raw view and a second `Object.is` gate suppresses publication while the raw view reference is unchanged. Carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut; an accidentally async view returns a Promise and fails `wire.viewSchema.parse`. ### Source map @@ -90,7 +90,7 @@ The package is the Service Definition and drive role of a capability seam: the f ### Drive and checkpoint flow -One committed event drives every registered unit in registration order; a changed client-visible unit notifies the change feed with its schema-validated view and the causing seq. `checkpoint(session)` returns one detached `(key → {ver, seq, val})` row per unit for the persisted cache; `restoreFloor` anchors a tail read one event below the lowest usable watermark so a shrunk log is detected, and `restore` refolds persisted rows over a stored suffix, discarding any row whose `ver` does not match or that claims events past the stored end. +One committed event drives every registered unit in registration order; a client-visible unit whose raw view changes by `Object.is` notifies the change feed with its schema-validated view and the causing seq. The live drive retains its previous and current raw views; snapshots and cold reads remain complete independent reads. `checkpoint(session)` returns one detached `(key → {ver, seq, val})` row per unit for the persisted cache; `restoreFloor` anchors a tail read one event below the lowest usable watermark so a shrunk log is detected, and `restore` refolds persisted rows over a stored suffix, discarding any row whose `ver` does not match or that claims events past the stored end. @@ -127,7 +127,7 @@ These limits define where the projection registry needs care at scale. They are - **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states, revisit if a domain's value grows large. - **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by any agent preset appears in every session's snapshot; a client must read the value rather than treat an absent key as absence of the feature. -- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters. +- **Eager drive touches every unit per event** — cheap by construction (whole-value rule and state/view reference gates), but a hot path would justify per-unit event-type prefilters. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index ab051795bd..996c518876 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -51,7 +51,7 @@ const definition = { } ``` -`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。 +`apply` 必须同步,且对与单元无关的事件必须返回同一个状态引用——引用不变意味着零下游工作。注册表用 `Object.is` 比较相邻的 `wire.view` 原始结果;对象或数组 view 若要在仅内部 state 变化时抑制发布,就必须复用引用,结构相同的新对象仍算变化。携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。 ### 注册与读取 @@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 设计理念 -本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——视图按状态对象身份做备忘,每个不同状态的视图只计算一次,且不存在会在监听器换代期间过期的「上次交付」记录(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 +本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。第一层 `Object.is` 闸门在 state 引用不变时跳过 view 工作;live drive 的双槽缓存复用前一个原始 view,第二层 `Object.is` 闸门在原始 view 引用不变时抑制发布。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。 ### 源码地图 @@ -90,7 +90,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ### 驱动与检查点流程 -一个已提交事件按注册顺序驱动每个已注册单元;状态引用变化的客户端可见单元会以经 schema 校验的视图与致因 seq 通知变更流。`checkpoint(session)` 为持久缓存返回每个单元一份独立的 `(key → {ver, seq, val})` 行;`restoreFloor` 把尾部读取锚定在最低可用水位之前一个事件处,使缩短的日志可被检出;`restore` 把持久行在存储后缀上重新折叠,丢弃任何 `ver` 不匹配或声称越过存储末尾的行。 +一个已提交事件按注册顺序驱动每个已注册单元;原始 view 通过 `Object.is` 判定为变化的客户端可见单元会以经 schema 校验的视图与致因 seq 通知变更流。live drive 保留前后两个原始 view;snapshot 与冷读仍是彼此独立的完整读取。`checkpoint(session)` 为持久缓存返回每个单元一份独立的 `(key → {ver, seq, val})` 行;`restoreFloor` 把尾部读取锚定在最低可用水位之前一个事件处,使缩短的日志可被检出;`restore` 把持久行在存储后缀上重新折叠,丢弃任何 `ver` 不匹配或声称越过存储末尾的行。 @@ -127,7 +127,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) - **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态时可以接受,若某领域的值变大再重议。 - **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——任何 agent preset 注册的 key 都会出现在每个会话的快照里;客户端必须读值,不能把 key 缺席当作功能缺席。 -- **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。 +- **主动驱动逐事件触达每个单元**——按构造开销很低(全量值规则与 state/view 引用闸门),但若出现热点路径,可加按单元的事件类型预过滤。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关。 diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 27e2e389cc..2910eee9be 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -67,7 +67,10 @@ export interface ProjectionDefinition< /** Validates the wire payload before it leaves the host. */ viewSchema: ZodType /** - * State → wire payload (the read-side projection). + * State → wire payload (the read-side projection). The live drive keeps + * the two latest raw results and compares them with `Object.is`; an + * object-valued view must reuse its reference to suppress publication + * across internal-only state changes. * @param state - the current state. * @returns the whole current value for this unit's key. */ @@ -83,12 +86,9 @@ export interface ProjectionDefinition< } /** - * Change-feed listener: one unit's served value changed for one session. - * `value` is the schema-validated `view` output; `seq` is the unit's - * watermark at emission (the seq of the event that caused the change). A - * changed state whose raw `view` output is `Object.is`-identical to the - * unit's previous projection does not fire, so a unit can buffer working - * fields in state behind an identity-stable projection. + * Change-feed listener: one unit's raw `view` result changed by `Object.is` + * for one session. `value` is the schema-validated output; `seq` is the + * unit's watermark at emission (the seq of the event that caused the change). */ export type ProjectionChangeListener = ( session: Session, @@ -139,11 +139,13 @@ interface ErasedDefinition { stateVersion: number } -/** Per-session per-unit watermark cache row. */ +/** Per-session per-unit watermark and fixed live-drive view buffer. */ interface UnitCell { state: unknown /** Seq of the last event passed through `apply` (regardless of change). */ observedSeq: number + /** `[previousView, currentView]`; undefined slots mean no cached comparison. */ + readonly views: [unknown, unknown] } /** @@ -160,13 +162,6 @@ interface UnitCell { interface Registration { readonly def: ErasedDefinition readonly cells: WeakMap - /** - * Raw `view` output per state object (pure-view memo). An entry is the - * view of that exact state — not a last-delivered record — so it cannot go - * stale; a missing entry recomputes. Weak keys die with their states; - * primitive states bypass the memo. - */ - readonly viewMemo: WeakMap /** Live registrants sharing this unit; the last one out removes the key. */ refs: number } @@ -174,13 +169,9 @@ interface Registration { /** * `ctx.sessionProjections`: the projection unit table and its drive. The * service subscribes to `session/event` once; every committed event passes - * every registered unit's `apply` (eager drive), and a changed state - * reference in a client-visible unit notifies the change feed with the - * schema-validated view — unless the raw view output is `Object.is`-identical - * to the unit's previous projection (identity-stable projections stay quiet). - * Views are memoized by state object identity, so each distinct state's view - * computes once and no last-delivered record exists to go stale across - * listener generations. + * every registered unit's `apply` (eager drive). A changed state reference + * computes the next client view; the change feed is notified only when its + * raw result changes by `Object.is`. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first * touch (event or read). Registration is an effect (disposer rides the @@ -210,6 +201,7 @@ export class SessionProjectionRegistry extends Service { registration.cells.set(session, { state: registration.def.init(session.header), observedSeq: -1, + views: [undefined, undefined], }) } }) @@ -270,7 +262,7 @@ export class SessionProjectionRegistry extends Service { const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: erased, cells: new WeakMap(), viewMemo: new WeakMap(), refs: 1 }) + this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) @@ -291,7 +283,7 @@ export class SessionProjectionRegistry extends Service { /** * Subscribe to the change feed. The registration is an effect on the * calling context's fiber. - * @param listener - called once per client-visible unit whose state reference changed, per committed event. + * @param listener - called once per client-visible unit whose raw view changed by `Object.is`, per committed event. * @returns the exact disposer that unsubscribes. */ onChanged(listener: ProjectionChangeListener): () => void { @@ -573,6 +565,7 @@ export class SessionProjectionRegistry extends Service { registration.cells.set(session, { state: row.val, observedSeq: row.seq, + views: [undefined, undefined], }) } return restored.snapshot @@ -591,7 +584,7 @@ export class SessionProjectionRegistry extends Service { ): UnitCell { let state = def.init(header) for (const event of events) state = def.apply(state, event) - return { state, observedSeq: (events.at(-1)?.seq ?? -1) } + return { state, observedSeq: (events.at(-1)?.seq ?? -1), views: [undefined, undefined] } } /** Read (or lazily build, folding the full in-memory log) one unit's cell. */ @@ -620,12 +613,16 @@ export class SessionProjectionRegistry extends Service { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } const next = def.apply(cell.state, event) + if (!Object.is(next, cell.state)) { + cell.views[0] = cell.views[1] + cell.views[1] = undefined + } cell.state = next cell.observedSeq = seq } } - /** Eager drive: pass one committed event through every registered unit; notify on changed references. */ + /** Eager drive: pass one committed event through every unit; notify on changed raw view references. */ private drive(session: Session, event: SessionEvent): void { for (const registration of this.registrations.values()) { let cell = registration.cells.get(session) @@ -638,22 +635,25 @@ export class SessionProjectionRegistry extends Service { } else { this.advanceCell(registration.def, cell, session.events, event.seq - 1) } - const previous = cell.state - const next = registration.def.apply(previous, event) - const changed = !Object.is(next, previous) + const previousState = cell.state + const next = registration.def.apply(previousState, event) + const changed = !Object.is(next, previousState) cell.state = next cell.observedSeq = event.seq - if (changed && registration.def.wire !== undefined && this.listeners.size > 0) { - // Identity gate on the raw view, memoized by state identity: the - // previous state's view was cached when that state was current, so - // each distinct state's view computes once and the quiet path - // allocates nothing. The memo cannot go stale — an entry is the view - // of that exact state, not a record of what the feed last delivered. - const raw = this.viewOf(registration.def.wire, registration.viewMemo, next) - if (Object.is(this.viewOf(registration.def.wire, registration.viewMemo, previous), raw)) continue - const value = registration.def.wire.viewSchema.parse(raw) - for (const listener of this.listeners) { - listener(session, registration.def.key as Extract, value, event.seq) + const wire = registration.def.wire + if (changed && wire !== undefined) { + const views = cell.views + views[0] = views[1] + if (this.listeners.size > 0) { + views[1] = wire.view(next) + if (!Object.is(views[0], views[1])) { + const value = wire.viewSchema.parse(views[1]) + for (const listener of this.listeners) { + listener(session, registration.def.key as Extract, value, event.seq) + } + } + } else { + views[1] = undefined } } } @@ -663,22 +663,7 @@ export class SessionProjectionRegistry extends Service { private viewCell(registration: Registration, cell: UnitCell): unknown { const wire = registration.def.wire if (wire === undefined) throw new Error(`session projection ${JSON.stringify(registration.def.key)} has no wire view`) - return wire.viewSchema.parse(this.viewOf(wire, registration.viewMemo, cell.state)) - } - - /** - * One unit's raw `view` output for one state, memoized by state object - * identity (the pure-view contract makes the entry permanently correct). - * Primitive states have no WeakMap key and compute directly. - * @param wire - the unit's wire block. - * @param memo - the unit's per-state view memo. - * @param state - a state produced by the unit's `init`/`apply`. - * @returns the raw (pre-validation) `view` output for that state. - */ - private viewOf(wire: NonNullable, memo: WeakMap, state: unknown): unknown { - if (typeof state !== 'object' || state === null) return wire.view(state) - if (!memo.has(state)) memo.set(state, wire.view(state)) - return memo.get(state) + return wire.viewSchema.parse(wire.view(cell.state)) } } diff --git a/packages/session/session-projection/src/invariant.ts b/packages/session/session-projection/src/invariant.ts index 537015d932..8d177bf835 100644 --- a/packages/session/session-projection/src/invariant.ts +++ b/packages/session/session-projection/src/invariant.ts @@ -16,8 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: the registry's own contracts (duplicate-key and - * stateVersion rejection, effect-tied removal, the Object.is change gate) are - * enforced synchronously inside the service and proven by its spec, the + * stateVersion rejection, effect-tied removal, and the state/view `Object.is` + * gates) are enforced synchronously inside the service and proven by its spec, the * drive relation (every committed `session/event` passes every unit) would * require re-running the drive to check — duplicating the implementation * rather than detecting drift — and the served-value relation (every served diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index ef12ef6a14..19c6419d28 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -1,13 +1,13 @@ /** * SessionProjectionRegistry unit drive: eager apply on committed events with * lazy cell build (registration after events, session after registration), - * the Object.is no-change gate (same reference ⇒ zero change-feed work), - * snapshot consistency (asOfSeq = last event seq; values from the watermark - * cache), duplicate-key rejection, stateVersion validation, and effect-tied - * removal of registrations and change listeners (HMR safety). + * the Object.is no-change gates (same state or raw view reference ⇒ zero + * change-feed work), snapshot consistency (asOfSeq = last event seq; values + * from the watermark cache), duplicate-key rejection, stateVersion validation, + * and effect-tied removal of registrations and change listeners (HMR safety). */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -19,14 +19,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/marks': MarksState 'test/count': number - 'test/buffered': { marks: string[]; draft: string } - 'test/label': string + 'test/stable-view': StableViewState } interface SessionProjectionMap { 'test/marks': { marks: string[] } - 'test/buffered': string[] - 'test/label': string + 'test/stable-view': { marks: string[] } } } @@ -36,7 +34,15 @@ declare module '@deepseek-ai/dsh-session/types' { } } -type MarksState = { marks: string[] } | null +interface MarksView { + marks: string[] +} +type MarksState = MarksView | null +interface StableViewState { + revision: number + value: MarksView +} +const marksViewSchema: z.ZodType = z.object({ marks: z.array(z.string()) }) const RESTORE_HEADER: SessionHeader = { version: 0, id: SessionId('projection-restore'), @@ -46,11 +52,11 @@ const RESTORE_HEADER: SessionHeader = { const marksUnit = (): Omit, 'wire'> & { wire: NonNullable['wire']> } => ({ key: 'test/marks', - stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), + stateSchema: marksViewSchema.nullable(), init: () => null, apply: (state, event) => (event.type === 'test/mark' ? (event).data : state), wire: { - viewSchema: z.object({ marks: z.array(z.string()) }), + viewSchema: marksViewSchema, view: state => state ?? { marks: [] }, }, stateVersion: 1, @@ -65,6 +71,27 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({ stateVersion: 1, }) +const stableViewUnit = ( + view: (state: StableViewState) => StableViewState['value'], +) => ({ + key: 'test/stable-view', + stateSchema: z.object({ + revision: z.number().int().nonnegative(), + value: marksViewSchema, + }), + init: () => ({ revision: 0, value: { marks: [] } }), + apply: (state, event) => { + if (event.type === 'turn/start') return { ...state, revision: state.revision + 1 } + if (event.type === 'test/mark') return { revision: state.revision + 1, value: event.data } + return state + }, + wire: { + viewSchema: marksViewSchema, + view, + }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/stable-view', StableViewState> + async function harness(): Promise<{ ctx: Context; session: Session }> { const ctx = new Context() await ctx.plugin(SessionStore) @@ -117,111 +144,62 @@ describe('SessionProjectionRegistry drive', () => { expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }]) }) - it('keeps the feed quiet while a changed state serves an identity-stable view (draft buffering)', async () => { + it('does not compute a view while no change listener exists', async () => { const { ctx, session } = await harness() - // Unit buffering a working field beside its wire array: the view projects - // only `marks`, whose identity survives draft-only applies. - ctx.sessionProjections.register({ - key: 'test/buffered', - stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }), - init: () => ({ marks: [], draft: '' }), - apply: (state, event) => { - if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' } - if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` } - return state - }, - wire: { viewSchema: z.array(z.string()), view: state => state.marks }, - stateVersion: 1, - }) - const seen: { value: unknown; seq: number }[] = [] - ctx.sessionProjections.onChanged((_session, key, value, seq) => { - if (key === 'test/buffered') seen.push({ value, seq }) - }) - const first = mark(session, ['a']) - // Draft-only applies change the state reference but not the served view. + const view = vi.fn((state: StableViewState) => state.value) + ctx.sessionProjections.register(stableViewUnit(view)) + session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 2 }) - const second = mark(session, ['a', 'b']) - expect(seen).toEqual([ - { value: ['a'], seq: first.seq }, - { value: ['a', 'b'], seq: second.seq }, - ]) - // The quiet applies still advanced the state itself. - expect(ctx.sessionProjections.stateOf(session, 'test/buffered')?.draft).toBe('') - expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) + + expect(ctx.sessionProjections.stateOf(session, 'test/stable-view')?.revision).toBe(2) + expect(view).not.toHaveBeenCalled() }) - it("computes each distinct state's view once: the memo serves previous states to the gate and snapshots", async () => { + it('publishes the first observed view and suppresses later same-reference views', async () => { const { ctx, session } = await harness() - let viewCalls = 0 - ctx.sessionProjections.register({ - key: 'test/buffered', - stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }), - init: () => ({ marks: [], draft: '' }), - apply: (state, event) => { - if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' } - if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` } - return state - }, - wire: { - viewSchema: z.array(z.string()), - view: (state) => { - viewCalls += 1 - return state.marks - }, - }, - stateVersion: 1, - }) + const view = vi.fn((state: StableViewState) => state.value) + ctx.sessionProjections.register(stableViewUnit(view)) + const seen: unknown[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { - if (key === 'test/buffered') seen.push(value) + if (key === 'test/stable-view') seen.push(value) }) - // First change touches two never-seen states (init and next): two calls. - mark(session, ['a']) - expect(viewCalls).toBe(2) - // Draft-only change: the new state computes, the previous is a memo hit. + session.append('turn/start', { turn: 1 }) - expect(viewCalls).toBe(3) - mark(session, ['a', 'b']) - expect(viewCalls).toBe(4) - expect(seen).toEqual([['a'], ['a', 'b']]) - // Snapshot reads reuse the same memo instead of recomputing the view. - expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b']) - expect(viewCalls).toBe(4) + session.append('turn/start', { turn: 2 }) + + expect(seen).toEqual([{ marks: [] }]) + expect(view).toHaveBeenCalledTimes(2) + + mark(session, ['changed']) + expect(seen).toEqual([{ marks: [] }, { marks: ['changed'] }]) + expect(view).toHaveBeenCalledTimes(3) }) - it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => { + it('publishes the first view after an unobserved state change', async () => { const { ctx, session } = await harness() - // A primitive-valued view compares by value under Object.is (the title - // unit's shape), which is exactly where remembering a delivered value — - // instead of comparing the two states in hand — would silence a real - // transition. - ctx.sessionProjections.register({ - key: 'test/label', - stateSchema: z.string(), - init: () => '', - apply: (state, event) => (event.type === 'test/mark' ? event.data.marks[0] ?? '' : state), - wire: { viewSchema: z.string(), view: state => state }, - stateVersion: 1, - }) - const first: string[] = [] + const view = vi.fn((state: StableViewState) => state.value) + ctx.sessionProjections.register(stableViewUnit(view)) + const first: unknown[] = [] const stop = ctx.sessionProjections.onChanged((_session, key, value) => { - if (key === 'test/label') first.push(value as string) + if (key === 'test/stable-view') first.push(value) }) - mark(session, ['A']) + + session.append('turn/start', { turn: 1 }) stop() - // Unobserved transition away from 'A'… - mark(session, ['B']) - // …then a new listener generation subscribes and the value returns: - // dedup memory frozen at the delivered 'A' would silence this delivery; - // the per-step previous-state comparison sees 'B' → 'A' and fires. - const second: string[] = [] + session.append('turn/start', { turn: 2 }) + expect(view).toHaveBeenCalledTimes(1) + + const resumed: unknown[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { - if (key === 'test/label') second.push(value as string) + if (key === 'test/stable-view') resumed.push(value) }) - mark(session, ['A']) - expect(first).toEqual(['A']) - expect(second).toEqual(['A']) + session.append('turn/start', { turn: 3 }) + + expect(first).toEqual([{ marks: [] }]) + expect(resumed).toEqual([{ marks: [] }]) + expect(view).toHaveBeenCalledTimes(2) }) it('drives independently per session (cells are per-session watermarks)', async () => { From 9ba9a35c7255ae846f4d930c9705257875e958b3 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 20:29:08 +0800 Subject: [PATCH 43/43] test(session-projection): cover view transition matrix --- .../session/session-projection/src/index.ts | 2 + .../session-projection/tests/registry.spec.ts | 170 ++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 2910eee9be..709c5f5f66 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -656,6 +656,8 @@ export class SessionProjectionRegistry extends Service { views[1] = undefined } } + // An unchanged state keeps its current view as the valid comparison + // value for the next state change. } } diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 19c6419d28..b5e32f41c4 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -102,6 +102,47 @@ async function harness(): Promise<{ ctx: Context; session: Session }> { const mark = (session: Session, marks: string[]): SessionEvent => session.append('test/mark', { marks }) +const STATE_SEQUENCES = [ + [0, 0, 0, 0], + [0, 0, 0, 1], + [0, 0, 1, 0], + [0, 0, 1, 1], + [0, 0, 1, 2], + [0, 1, 0, 0], + [0, 1, 0, 1], + [0, 1, 0, 2], + [0, 1, 1, 0], + [0, 1, 1, 1], + [0, 1, 1, 2], + [0, 1, 2, 0], + [0, 1, 2, 1], + [0, 1, 2, 2], + [0, 1, 2, 3], +] as const + +function identitySequences(length: number): number[][] { + const sequences: number[][] = [] + const visit = (sequence: number[], highest: number): void => { + if (sequence.length === length) { + sequences.push(sequence) + return + } + for (let value = 0; value <= highest + 1; value++) { + visit([...sequence, value], Math.max(highest, value)) + } + } + visit([0], 0) + return sequences +} + +function sameIdentities(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => Object.is(value, right[index])) +} + +function sequenceName(sequence: readonly number[], prefix: string): string { + return sequence.map(value => `${prefix}${String(value + 1)}`).join(',') +} + describe('SessionProjectionRegistry drive', () => { it('drives a registered unit over committed events and snapshots the current value', async () => { const { ctx, session } = await harness() @@ -202,6 +243,135 @@ describe('SessionProjectionRegistry drive', () => { expect(view).toHaveBeenCalledTimes(2) }) + it('matches every four-state identity sequence across listener gaps and raw-view identities', async () => { + const { ctx } = await harness() + const initialState: MarksState = { marks: ['initial'] } + const stateByEvent = new Map() + const viewByState = new Map() + const computedViews: MarksView[] = [] + ctx.sessionProjections.register({ + key: 'test/marks', + stateSchema: marksViewSchema.nullable(), + init: () => initialState, + apply: (state, event) => { + if (event.type !== 'test/mark') return state + const token = event.data.marks[0] + if (token === undefined || !stateByEvent.has(token)) return state + return stateByEvent.get(token) as MarksState + }, + wire: { + viewSchema: marksViewSchema, + view: (state) => { + const value = viewByState.get(state) + if (value === undefined) throw new Error('test state lacks a raw view') + computedViews.push(value) + return value + }, + }, + stateVersion: 1, + }) + + const failures = new Map() + let mismatchCount = 0 + let checked = 0 + for (const stateSequence of STATE_SEQUENCES) { + const stateCount = Math.max(...stateSequence) + 1 + for (const viewSequence of identitySequences(stateCount)) { + for (const baselineKnown of [false, true]) { + for (let listenerMask = 0; listenerMask < 8; listenerMask++) { + const scenario = String(checked++) + const states = Array.from( + { length: stateCount }, + (_, index): MarksState => ({ marks: [`state-${scenario}-${String(index)}`] }), + ) + const views = Array.from( + { length: Math.max(...viewSequence) + 1 }, + (): MarksView => ({ marks: [] }), + ) + for (let index = 0; index < stateCount; index++) { + viewByState.set(states[index] as MarksState, views[viewSequence[index] as number] as MarksView) + } + for (let index = 0; index < stateSequence.length; index++) { + stateByEvent.set(`${scenario}:${String(index)}`, states[stateSequence[index] as number] as MarksState) + } + + const session = ctx.sessions.create() + const notifications: number[] = [] + let stop: (() => void) | undefined + const setListening = (listening: boolean): void => { + if (listening && stop === undefined) { + stop = ctx.sessionProjections.onChanged((changedSession, key, _value, seq) => { + if (changedSession === session && key === 'test/marks') notifications.push(seq) + }) + } else if (!listening && stop !== undefined) { + stop() + stop = undefined + } + } + + setListening(baselineKnown) + mark(session, [`${scenario}:0`]) + computedViews.length = 0 + notifications.length = 0 + + const expectedViews: MarksView[] = [] + const expectedNotifications: number[] = [] + let comparable = baselineKnown + ? views[viewSequence[stateSequence[0] as number] as number] as MarksView + : undefined + for (let index = 1; index < stateSequence.length; index++) { + const listening = (listenerMask & (1 << (index - 1))) !== 0 + setListening(listening) + const changed = stateSequence[index] !== stateSequence[index - 1] + if (changed) { + if (listening) { + const current = views[viewSequence[stateSequence[index] as number] as number] as MarksView + expectedViews.push(current) + if (comparable === undefined || !Object.is(comparable, current)) { + expectedNotifications.push(index) + } + comparable = current + } else { + comparable = undefined + } + } + mark(session, [`${scenario}:${String(index)}`]) + } + setListening(false) + + if (!sameIdentities(computedViews, expectedViews) + || notifications.length !== expectedNotifications.length + || notifications.some((seq, index) => seq !== expectedNotifications[index])) { + mismatchCount += 1 + const stateName = sequenceName(stateSequence, 'v') + if (!failures.has(stateName) || (baselineKnown && listenerMask === 7)) { + failures.set(stateName, { + state: stateName, + view: stateSequence.map(value => `r${String((viewSequence[value] as number) + 1)}`).join(','), + baseline: baselineKnown ? 'known' : 'unknown', + listeners: [0, 1, 2] + .map(index => (listenerMask & (1 << index)) === 0 ? 'off' : 'on') + .join(','), + expectedViewCalls: expectedViews.length, + actualViewCalls: computedViews.length, + expectedNotifications, + actualNotifications: [...notifications], + }) + } + } + computedViews.length = 0 + } + } + } + } + + expect({ checked, mismatchCount, failures: [...failures.values()] }).toEqual({ + checked: 960, + mismatchCount: 0, + failures: [], + }) + }) + it('drives independently per session (cells are per-session watermarks)', async () => { const { ctx, session } = await harness() const other = ctx.sessions.create()