From 5660f44d29f47fca2612c92ecffe6fb699c486f1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 13:11:10 +0800 Subject: [PATCH 01/38] perf(session): separate indexed and snapshot log reads --- ...ev-invariants-over-deep-readonly.i18n.yaml | 4 +- ...06-11-dev-invariants-over-deep-readonly.md | 6 +- ...11-dev-invariants-over-deep-readonly.zh.md | 6 +- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...-26-packed-chunk-rows-by-default.i18n.yaml | 4 +- ...2026-07-26-packed-chunk-rows-by-default.md | 2 +- ...6-07-26-packed-chunk-rows-by-default.zh.md | 2 +- ...e-session-jsonl-restore-pipeline.i18n.yaml | 4 +- ...05-large-session-jsonl-restore-pipeline.md | 2 +- ...large-session-jsonl-restore-pipeline.zh.md | 2 +- ...26-08-21-session-log-read-intent.i18n.yaml | 6 + .../2026-08-21-session-log-read-intent.md | 32 +++++ .../2026-08-21-session-log-read-intent.zh.md | 32 +++++ ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 2 +- ...07-29-human-transcript-append-origin.zh.md | 2 +- ...ontext-meter-blind-to-compaction.i18n.yaml | 4 +- ...08-05-context-meter-blind-to-compaction.md | 2 +- ...05-context-meter-blind-to-compaction.zh.md | 2 +- ...26-08-05-context-form-vocabulary.i18n.yaml | 4 +- .../2026-08-05-context-form-vocabulary.md | 2 +- .../2026-08-05-context-form-vocabulary.zh.md | 2 +- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 2 +- ...-05-feedback-gated-session-telemetry.zh.md | 2 +- ...2026-07-06-recallable-compaction.i18n.yaml | 4 +- .../2026-07-06-recallable-compaction.md | 2 +- .../2026-07-06-recallable-compaction.zh.md | 2 +- .../headless/tests/coding-task.e2e.ts | 2 +- .../profiles/headless/tests/compaction.e2e.ts | 2 +- .../profiles/headless/tests/full-loop.e2e.ts | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 6 +- .../profiles/headless/tests/resume.e2e.ts | 2 +- .../profiles/headless/tests/todo-write.e2e.ts | 2 +- .../tests/chat-continuous-conversation.e2e.ts | 2 +- apps/web/tests/chat-long-interactions.e2e.ts | 22 ++-- apps/web/tests/chat-scroll-fixture.ts | 2 +- apps/web/tests/complex-history.perf.ts | 2 +- .../tests/goal-command-presentation.e2e.ts | 2 +- apps/web/tests/markdown-cjk-strong.e2e.ts | 2 +- apps/web/tests/markdown-images.e2e.ts | 2 +- .../tests/markdown-inline-code-links.e2e.ts | 2 +- apps/web/tests/markdown-wide-table.e2e.ts | 2 +- apps/web/tests/math-rendering.e2e.ts | 2 +- apps/web/tests/minimal-preset.snapshot.ts | 2 +- apps/web/tests/produced-file-mentions.e2e.ts | 2 +- apps/web/tests/produced-files.e2e.ts | 2 +- apps/web/tests/reference-composer.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 4 +- apps/web/tests/schedule-after.e2e.ts | 16 +-- apps/web/tests/seeded-history.e2e.ts | 2 +- apps/web/tests/settings-chrome.e2e.ts | 8 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +- apps/web/tests/subagent-interrupt.e2e.ts | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 31 +++-- docs/subsystems/session.zh.md | 31 +++-- packages/acp/acp/tests/approval.spec.ts | 2 +- packages/acp/acp/tests/bridge.spec.ts | 4 +- packages/acp/acp/tests/turns.spec.ts | 10 +- packages/api/remotes/tests/built-lib.e2e.ts | 4 +- .../api/session-controller/src/commands.ts | 2 +- .../api/session-controller/src/history.ts | 2 +- packages/api/session-controller/src/index.ts | 2 +- .../tests/agent.host.spec.ts | 8 +- .../tests/session-cold.host.spec.ts | 2 +- .../tests/session-fork.host.spec.ts | 12 +- .../session-history-journal.host.spec.ts | 4 +- .../tests/session-projections.host.spec.ts | 4 +- .../tests/session-rename.host.spec.ts | 2 +- .../tests/transport.host.spec.ts | 27 ++-- packages/bundle/headless/src/index.ts | 14 +- .../tests/command-compact.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../compaction/compaction-basic/src/region.ts | 13 +- .../tests/compaction-basic.spec.ts | 52 ++++---- .../tests/compaction-loop-repro.spec.ts | 18 +-- .../tests/manual-compaction.spec.ts | 60 ++++----- .../src/index.ts | 2 +- .../tests/tool-result-pruner.spec.ts | 8 +- .../compaction/compaction/src/invariant.ts | 5 +- .../compaction/compaction/src/tool-pairing.ts | 16 +-- .../compaction/tests/compaction.spec.ts | 6 +- .../compaction/tests/invariant.spec.ts | 16 +-- .../compaction/tests/tool-pairing.spec.ts | 45 +++---- .../context/agent-instructions/src/index.ts | 4 +- .../context/agent-instructions/src/state.ts | 2 +- .../tests/agent-instructions.e2e.ts | 6 +- .../tests/agent-instructions.spec.ts | 68 +++++----- .../tests/session-reference.spec.ts | 2 +- packages/context/time-context/src/index.ts | 5 +- .../context/time-context/src/invariant.ts | 7 +- .../time-context/tests/time-context.spec.ts | 26 ++-- .../tmux-context/tests/tmux-context.spec.ts | 4 +- packages/core/agent-loop/src/invariant.ts | 2 +- .../core/agent-loop/src/runtime-context.ts | 4 +- .../agent-loop/tests/agent-initiator.spec.ts | 2 +- packages/core/agent-loop/tests/agent.spec.ts | 8 +- packages/core/agent-loop/tests/cancel.spec.ts | 78 +++++------ .../tests/contract-regressions.spec.ts | 78 +++++------ .../agent-loop/tests/coverage-edges.spec.ts | 36 +++--- .../agent-loop/tests/interception.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 114 ++++++++--------- .../core/agent-loop/tests/properties.spec.ts | 8 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../agent-loop/tests/request-error.spec.ts | 12 +- .../tests/request-reconstruction.spec.ts | 56 ++++---- packages/core/agent-loop/tests/resume.spec.ts | 14 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 6 +- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- .../core/agent-loop/tests/tool-order.spec.ts | 16 +-- packages/core/agent/src/inbox.ts | 2 +- packages/core/agent/tests/agent.spec.ts | 6 +- .../core/agent/tests/consumed-work.spec.ts | 26 ++-- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 6 + packages/core/session/README.zh.md | 6 + packages/core/session/src/chunk-rows.ts | 2 +- packages/core/session/src/index.ts | 57 ++++++--- packages/core/session/src/invariant.ts | 2 +- .../core/session/tests/derived-cache.spec.ts | 2 +- packages/core/session/tests/fork.spec.ts | 30 ++--- packages/core/session/tests/invariant.spec.ts | 4 +- .../core/session/tests/properties.spec.ts | 16 +-- .../core/session/tests/request-header.spec.ts | 6 +- packages/core/session/tests/session.spec.ts | 121 ++++++++++-------- packages/core/session/tests/surface.spec.ts | 40 +++--- packages/core/tools/src/invariant.ts | 2 +- packages/core/tools/tests/invariant.spec.ts | 2 +- packages/core/tools/tests/ptc.spec.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 6 +- .../experimental/agent-team/src/mailbox.ts | 2 +- .../experimental/agent-team/src/roster.ts | 4 +- .../agent-team/tests/invariant.spec.ts | 4 +- .../agent-team/tests/persistence.spec.ts | 2 +- .../agent-team/tests/team.spec.ts | 8 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../tests/command-feedback.spec.ts | 20 +-- .../tests/loader-composition.spec.ts | 8 +- .../message-feedback/tests/helpers.ts | 4 +- .../tests/message-feedback.spec.ts | 8 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 2 +- .../command-goal/tests/command-goal.spec.ts | 12 +- .../goal/goal-round-driver/src/invariant.ts | 4 +- .../tests/goal-round-driver.spec.ts | 18 +-- packages/goal/goal/src/invariant.ts | 2 +- packages/goal/goal/tests/goal.spec.ts | 56 ++++---- packages/goal/goal/tests/projection.spec.ts | 2 +- packages/goal/tool-goal/src/authority.ts | 2 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 4 +- .../tests/repeat-tool-reminder.spec.ts | 6 +- packages/hooks/hook-protocol/src/invariant.ts | 2 +- .../hooks/hook-protocol/tests/events.spec.ts | 18 +-- .../hooks-claude-code/tests/bridge.spec.ts | 2 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 2 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- .../interaction/commands/src/invariant.ts | 4 +- .../commands/tests/commands.spec.ts | 10 +- .../permission-presets/src/invariant.ts | 2 +- .../tests/permission-presets.spec.ts | 36 +++--- .../tests/projection.spec.ts | 8 +- .../interaction/user-approval/src/index.ts | 4 +- .../user-approval/src/invariant.ts | 2 +- .../user-approval/tests/approval.spec.ts | 30 ++--- packages/llm/llm-retry/src/invariant.ts | 11 +- .../tests/loader-composition.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 50 ++++---- .../tests/transport-recovery.spec.ts | 34 ++--- packages/llm/token-meter/src/index.ts | 6 +- .../llm/token-meter/tests/token-meter.spec.ts | 14 +- packages/plan/plan-mode/src/invariant.ts | 2 +- .../plan/plan-mode/tests/integration.spec.ts | 10 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 78 +++++------ .../plan/plan-mode/tests/projection.spec.ts | 2 +- .../preset/agent-presets/tests/remote.spec.ts | 2 +- .../sandbox/sandbox-policy/src/invariant.ts | 2 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 2 +- packages/schedule/schedule/src/invariant.ts | 6 +- packages/schedule/schedule/src/runtime.ts | 2 +- packages/schedule/schedule/src/tools.ts | 2 +- .../schedule/schedule/tests/invariant.spec.ts | 10 +- .../schedule/tests/jsonl-restart.spec.ts | 2 +- .../schedule/schedule/tests/runtime.spec.ts | 34 ++--- .../schedule/schedule/tests/tools.spec.ts | 30 ++--- .../tests/loader-composition.host.spec.ts | 2 +- .../session-query-sqlite/src/index.ts | 2 +- .../session-query/session-query/src/corpus.ts | 4 +- .../session-query/src/observation.ts | 2 +- .../session-query/tests/observation.spec.ts | 2 +- .../session-query/tests/session-query.spec.ts | 6 +- .../src/workspace-access.ts | 2 +- .../tests/tool-session-query.spec.ts | 7 +- .../session/session-log-deepseek/src/index.ts | 18 +-- .../session-log-deepseek/src/invariant.ts | 2 +- .../session-log-deepseek/tests/upload.spec.ts | 17 +-- .../tests/jsonl.spec.ts | 10 +- .../session-persistence/src/coordinator.ts | 12 +- .../tests/persistence.spec.ts | 16 +-- .../session/session-projection/src/index.ts | 12 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry-otel/src/index.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 4 +- .../session-telemetry/src/coordinator.ts | 7 +- .../session-telemetry/tests/redact.spec.ts | 4 +- .../session-telemetry/tests/telemetry.spec.ts | 12 +- .../tests/provider.spec.ts | 2 +- .../session-title-llm/tests/llm.spec.ts | 8 +- packages/session/session-title/src/index.ts | 4 +- .../session-title/tests/provider.spec.ts | 6 +- .../session-title/tests/rename.spec.ts | 6 +- .../tests/service-contracts.spec.ts | 12 +- .../session-title/tests/session-title.spec.ts | 10 +- .../shell/tool-bash/tests/integration.spec.ts | 2 +- packages/shell/tool-bash/tests/tools.spec.ts | 4 +- packages/shell/tool-pwsh/tests/tools.spec.ts | 6 +- packages/skill/tool-skill/src/index.ts | 12 +- .../skill/tool-skill/tests/tool-skill.spec.ts | 4 +- .../subagent-fork-in-process/src/index.ts | 2 +- .../tests/multi-subagent.spec.ts | 8 +- .../tests/subagent-fork-in-process.spec.ts | 16 +-- .../subagent-in-process-driver/src/index.ts | 2 +- .../tests/inheritance.spec.ts | 24 ++-- .../tests/preset-inheritance.spec.ts | 2 +- .../tests/structured.spec.ts | 12 +- .../tests/subagent-in-process-driver.spec.ts | 10 +- .../tests/spawn-in-process.e2e.ts | 2 +- .../tests/subagent-spawn-in-process.spec.ts | 6 +- .../subagent/subagent/src/descriptor-seed.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 4 +- .../subagent/tests/continuation.spec.ts | 20 +-- .../tests/tool-subagent-report.spec.ts | 2 +- .../tests/model-selection-settings.spec.ts | 4 +- .../terminal-bash/tests/index.spec.ts | 4 +- .../fixtures/workspace-context-compaction.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 2 +- .../todo/tool-todo/tests/integration.spec.ts | 6 +- .../todo/tool-todo/tests/invariant.spec.ts | 4 +- .../tests/loader-composition.spec.ts | 4 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 20 +-- .../workflow/tool-workflow/src/invariant.ts | 2 +- .../tool-workflow/tests/tool-workflow.spec.ts | 14 +- 251 files changed, 1371 insertions(+), 1207 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md create mode 100644 .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 44272f7ca1..3bbc91fa87 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md -2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff +2026-06-11-dev-invariants-over-deep-readonly.md: 91d9ba2e459a02dc65b95a179d11e2f14af7e28d +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 750aee7ec543979f88a6bb606c00bd789de45c51 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 66980f1ee0..91d9ba2e45 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references. -The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. +The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered. @@ -48,12 +48,12 @@ Freezing history only when an invariants plugin is installed would make the core ### Clone only when deriving messages -Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. +Detaching `deriveMessages()` would protect the most common request path but leave other readers of `snapshotEvents()`, `eventAt()`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute. ## Consequences - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. -- `session.events` exposes stable immutable snapshots instead of the private growing array. +- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array. - Request-side mutation cannot reach stored history through derived messages. - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 576e53e0b2..750aee7ec5 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -48,12 +48,12 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 仅在派生消息时克隆 -分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 +分离 `deriveMessages()` 能保护最常见的请求路径,但 `snapshotEvents()`、`eventAt()` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。 ## 后果 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 -- `session.events` 暴露稳定的不可变快照,而非持续增长的私有数组。 +- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 1548b145b4..82350dcab5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df +2026-07-25-web-client-session-scope-and-provide-channel.md: 94d13a4cdc9771a7a500f5e6d5158e42ee67374a +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: c462e1f574fe1bc2db14672395a27e459e6ab7a8 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 1fa442e8db..94d13a4cdc 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -61,7 +61,7 @@ Session instances share the scope's lifecycle; liveness eligibility = host-liste A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The host criterion: `session.events.length === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. +- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the lazy-create contract guarantees a never-appended session never enters `persistence.list()` at all (both the JSONL and SQLite backends are verified truly lazy), so blank never touches disk. - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index ea9a6e2474..c462e1f574 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -61,7 +61,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.events.length === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 +- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——lazy-create 约定保证 never-appended 会话根本不进 `persistence.list()`(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml index de962f5749..58dcf1ee20 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.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-26-packed-chunk-rows-by-default.md -2026-07-26-packed-chunk-rows-by-default.md: 14da6b3cbe650e80118e7c960c96bf618acd1e48 -2026-07-26-packed-chunk-rows-by-default.zh.md: f62a8e52a67adc960ac3150552594b4f061e6205 +2026-07-26-packed-chunk-rows-by-default.md: bd4b3b9f773afbf6aa7e88d51b6e842d6634c222 +2026-07-26-packed-chunk-rows-by-default.zh.md: eafe6632150aadd74395f4d0f09d064fb703a03d diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md index 14da6b3cbe..bd4b3b9f77 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.md @@ -18,7 +18,7 @@ Reading is unconditional and layout-blind. Packed, unpacked, and mixed files loa ### Logical events and physical rows -The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters `Session.events` or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. +The JSONL packing path stays at the `dsh-session` storage seam through `packChunkRuns()` and `decodeStorageRecord()`. The encoder recognizes exact delta-event shapes, preserves unrecognized events verbatim, and packs only runs of at least three. A packed row is encoding vocabulary, not a `SessionEventMap` member: it never enters the Session log or fires `session/event`. The [packed session-history transport decision](2026-08-15-packed-session-history-transport.md) reuses this vocabulary for a bounded lossless wire interval without changing those event semantics. The JSONL backend packs each durable append batch. Raw `compression: 'none'` and default Zstandard framing carry the same logical storage records; selecting raw mode for reviewable fixtures does not disable packing. Repository replay readers and normalizers decode the shared row format instead of maintaining snapshot-specific codecs. diff --git a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md index f62a8e52a6..eafe663215 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-packed-chunk-rows-by-default.zh.md @@ -18,7 +18,7 @@ JSONL 存储 seam 可以在不改变逻辑日志的情况下减少这部分封 ### 逻辑事件与物理行 -JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 `Session.events`,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 +JSONL 打包路径保留在 `dsh-session` 的存储 seam,并通过 `packChunkRuns()` 和 `decodeStorageRecord()` 实现。编码器识别精确的增量事件形态,原样保留无法识别的事件,并且只打包至少包含 3 个事件的连续段。打包行属于编码词汇,不是 `SessionEventMap` 成员:它绝不会进入 Session 日志,也不会触发 `session/event`。[打包会话历史传输决策](2026-08-15-packed-session-history-transport.zh.md)会为有界的无损协议区间复用该词汇,而不改变这些事件语义。 JSONL 后端会打包每个持久追加批次。原始模式 `compression: 'none'` 与默认 Zstandard 帧承载相同的逻辑存储记录;为使 fixture 便于评审而选择原始模式,不会禁用打包。仓库中的回放读取器和规范化器会解码共享行格式,而不维护快照专用编解码器。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml index 6f00f147f3..18313964ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-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-08-05-large-session-jsonl-restore-pipeline.md -2026-08-05-large-session-jsonl-restore-pipeline.md: eab53c683880ef7095233ed8122e532eb5add547 -2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 2cd0d2ca20074d6adb0735db08071638ae2ced88 +2026-08-05-large-session-jsonl-restore-pipeline.md: 309d9dc6bdb5c3160f3e6e76a8318915df58fe59 +2026-08-05-large-session-jsonl-restore-pipeline.zh.md: 28acd3ebe804dca22a0619c257ff3ad9c09500a9 diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md index eab53c6838..309d9dc6bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.md @@ -41,7 +41,7 @@ Borrowed seeds used by ordinary creation and fork paths still take a JSON snapsh - **Concatenate all plaintext before scanning** — rejected because it retains the compressed input, complete plaintext, whole-log UTF-8 string, line metadata, and parsed rows at the same time, and it rescans a torn-frame prefix. - **Implement a streaming JSON parser** — rejected because JSONL already provides record boundaries; native newline search plus `JSON.parse` removes the large intermediates without owning another parser or changing JSON semantics. - **Use a shared `WeakSet` while freezing restored events** — rejected because JSON materialization cannot produce cycles, and the set adds a lookup per object while retaining the complete graph during traversal. -- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and `Session.events` promises immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. +- **Skip validation or freezing for restored values** — rejected because durable storage is a runtime boundary and Session read methods promise immutable accepted history. The optimized path specializes those operations around stronger ownership facts instead of removing them. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md index 2cd0d2ca20..28acd3ebe8 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-large-session-jsonl-restore-pipeline.zh.md @@ -41,7 +41,7 @@ Zstandard 结构扫描器会在解码前识别完整帧范围。系统单独解 - **扫描前拼接全部明文**:不予采纳,因为该方案会同时保留压缩输入、完整明文、整份日志的 UTF-8 字符串、行元数据和解析记录,并会重新扫描撕裂帧前缀。 - **实现流式 JSON 解析器**:不予采纳,因为 JSONL 已提供记录边界;使用原生换行搜索与 `JSON.parse` 就能移除大型中间结构,无需自行维护另一套解析器或改变 JSON 语义。 - **冻结恢复事件时共享一个 `WeakSet`**:不予采纳,因为 JSON 物化不可能产生循环引用,而该集合会对每个对象增加一次查找,并在遍历期间保留完整对象图。 -- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 `Session.events` 承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 +- **跳过恢复值的校验或冻结**:不予采纳,因为持久存储属于运行时边界,而 Session 读取方法承诺已接受历史不可变。优化路径利用更强的所有权事实特化这些操作,而不是将其移除。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml new file mode 100644 index 0000000000..c002ef3859 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md +2026-08-21-session-log-read-intent.md: 81cdd770886b24588052d9860e42e4a07650fb03 +2026-08-21-session-log-read-intent.zh.md: e5d0425981e6425323260661bee42281b1e96c98 diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md new file mode 100644 index 0000000000..81cdd77088 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.md @@ -0,0 +1,32 @@ +# Agent Note: Session log reads state their materialization cost + +Status: implemented + +English | [中文](2026-08-21-session-log-read-intent.zh.md) + +## Problem + +An all-purpose `Session.events` accessor hides an array-sized copy behind every read after an append. The full frozen snapshot can be cached, but streaming invalidates that cache for every new event, so a caller that only needs the log length or one event can repeatedly copy millions of references. Making the return value immutable does not require every read intent to pay that cost; event immutability is owned separately by the [source-owned session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md). + +## Decision + +`Session` exposes three cost-specific read operations. `seq` reads the current length in constant time, `eventAt(seq)` reads one event in constant time, and `snapshotEvents(fromSeq?, toSeqExclusive?)` explicitly materializes a frozen array for consumers that need array operations or a stable materialized range. Sequence parameters are non-negative log positions, not `Array.prototype.slice` offsets from the end. Recurring domain state such as the selected agent preset is read from a Session projection instead of rescanning live history. + +The complete current snapshot is cached until append because repeated whole-log consumers can share the same immutable array. A range snapshot copies only the selected references and is not cached: arbitrary range caching would retain unbounded arrays and require an eviction policy. Previously returned snapshots remain stable because accepted events are immutable and a snapshot array never grows after append. + +Recurring domain-state reads use [session projections](2026-08-19-session-projection-state-and-client-views.md) when the required value can be maintained incrementally. Raw-log snapshots remain appropriate for persistence, export, replay, and consumers whose output is the event sequence itself. This API makes materialization visible but does not attempt to eliminate every full-log fold in the same change. + +## Alternatives considered + +**Keep a cached `events` array accessor.** This preserves ordinary array syntax but makes scalar and indexed reads appear cheap while an append can turn either into a whole-log copy. + +**Return a custom immutable cut with array-like traversal operations.** A captured length could provide a stable constant-time cut over the growing log, but the abstraction would reimplement selected array semantics and keep expanding as callers request more operations. Explicit indexed reads, explicit materialization, and projections cover the shipped intents with a smaller public API. + +**Cache every materialized range or maintain an incremental chunked snapshot.** Range caching needs retention and eviction rules, while a chunked public representation changes consumers and serialization for a cost that many callers avoid through indexed reads or projections. These representations remain options if measured full-snapshot consumers justify them. + +## Consequences + +- Length and single-event reads do not copy the log. +- Full and ranged snapshots retain an explicit linear cost proportional to the selected event count. +- Consumers choose between raw history and incrementally maintained state at the call site. +- The public API does not emulate an array; callers materialize only when they need array operations. diff --git a/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md new file mode 100644 index 0000000000..e5d0425981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-21-session-log-read-intent.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话日志读取显式表达物化成本 + +Status: implemented + +[English](2026-08-21-session-log-read-intent.md) | 中文 + +## 问题 + +通用的 `Session.events` 访问器会在每次 append 后的首次读取中隐藏一次与数组大小成正比的复制。完整冻结快照可以缓存,但流式输出的每个新事件都会使缓存失效,因此只需要日志长度或单个事件的调用方也可能反复复制数百万个引用。返回值不可变并不要求每种读取意图都承担该成本;事件不可变性由[源端拥有的会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)另行负责。 + +## 决策 + +`Session` 提供三种成本不同的读取操作。`seq` 以常数时间读取当前长度,`eventAt(seq)` 以常数时间读取一个事件,`snapshotEvents(fromSeq?, toSeqExclusive?)` 则为需要数组操作或稳定物化区间的消费方显式物化冻结数组。序列参数是非负日志位置,不是从末尾计算的 `Array.prototype.slice` 偏移量。所选 agent preset 这类重复读取的领域状态来自会话投影,而不是反复扫描活跃日志。 + +当前完整快照会缓存到下一次 append,使重复读取整个日志的消费方可以共享同一个不可变数组。区间快照只复制所选引用且不缓存:缓存任意区间会保留数量无界的数组,并要求额外的淘汰策略。先前返回的快照保持稳定,因为已接受的事件不可变,且快照数组不会在 append 后增长。 + +当所需值可以增量维护时,重复的领域状态读取使用[会话投影](2026-08-19-session-projection-state-and-client-views.zh.md)。持久化、导出、回放,以及输出本身就是事件序列的消费方仍适合使用原始日志快照。此 API 使物化成本显式可见,但不会在同一项改动中消除所有完整日志折叠。 + +## 曾考虑的替代方案 + +**保留缓存的 `events` 数组访问器。** 这保留了普通数组语法,但会让标量读取和索引读取看似廉价,而一次 append 就可能使其中任何一次读取变成完整日志复制。 + +**返回提供类数组遍历操作的自定义不可变 cut。** 捕获长度可以在持续增长的日志上提供稳定且常数时间的 cut,但随着调用方要求更多操作,该抽象会不断重新实现所选数组语义。显式索引读取、显式物化和投影以更小的公开 API 覆盖了已经交付的读取意图。 + +**缓存每个物化区间或维护增量分片快照。** 区间缓存需要保留与淘汰规则,而公开的分片表示会改变消费方和序列化方式;许多调用方已通过索引读取或投影避开这项成本。若实测的完整快照消费方证明有必要,仍可考虑这些表示。 + +## 后果 + +- 长度读取和单事件读取不会复制日志。 +- 完整快照和区间快照仍有与所选事件数量成正比的显式线性成本。 +- 消费方在调用点选择原始历史或增量维护的状态。 +- 公开 API 不模拟数组;调用方只在需要数组操作时物化。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 7593352560..d8a34eb622 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 72cafb5a1a149bcdb73d885d4e4fc4ac01e48cd2 -2026-07-29-human-transcript-append-origin.zh.md: 79fcc56b7b60082ebd2946d70419a659ff5687b7 +2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc +2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 72cafb5a1a..00f4cf0913 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -8,7 +8,7 @@ English | [中文](2026-07-29-human-transcript-append-origin.zh.md) The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessages` counted every `user/message` and `assistant/message` in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only `compaction/summary` event and the replacement that cites it. -Nothing was lost from the log. `Session.events` still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. +Nothing was lost from the log. `Session.snapshotEvents()` still returned every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 79fcc56b7b..631ba71d84 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -8,7 +8,7 @@ Status: implemented 终端与宿主历史网关都把模型可见的 surface 当作 transcript(文本记录)。一次成功的压缩(compaction)会用一个检查点节点替换一段 surface 范围,因此该替换一落地,终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页:`maxMessages` 统计窗口内的每个 `user/message` 和 `assistant/message`,于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度,而切分点还可能落在压缩的仅日志 `compaction/summary` 事件与引用它的替换之间。 -日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 +日志本身没有丢失任何内容。`Session.snapshotEvents()` 仍返回每条原始消息和完整的工具结果;surface 只决定接下来发送给模型的内容。缺陷完全在投影层。 ## 决策 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 0bf2cfa13f..abaae859ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: daf11b41236943d5fb35e9363c128ce470c7f4e9 -2026-08-05-context-meter-blind-to-compaction.zh.md: 6ea2f8814c4731fdf7c08d8b867cd7086ceccdbf +2026-08-05-context-meter-blind-to-compaction.md: a23e51771c74e350fd3fb04e3a23ab3cdfb6092e +2026-08-05-context-meter-blind-to-compaction.zh.md: 12fd41af0d8c8864a120f26d91148543f74b2e2b diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index daf11b4123..a23e51771c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -27,7 +27,7 @@ This reverses the "the ring, header, and bar length stay provider-exact" half of ## Alternatives considered -**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.events[seq]`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. +**Project `measure().totalTokens` instead.** The measurement service already composes exactly this (`baseline` anchor plus signed `surfaceDeltaTokens`), and it reacts correctly — measured at 4383 → 304 across the same compaction. But it is a service over private replay state, not a pure fold, and a projection cannot call it. Reproducing its anchor inside a `ProjectionDefinition` needs `_estimateProviderAssistant`'s random access to the chunk events cited by seq (`session.eventAt(seq)`), which `apply(state, event)` does not have. Anchoring on the sampled surface total is the same idea reachable from a pure per-event fold. **Emit a synthetic usage record at the end of compaction.** Would move `pressureTokens` itself, but the only usage compaction holds is the summarization request's own — a different prompt entirely. Recording it as the conversation's prompt size would be a lie in the durable log rather than in one display. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index 6ea2f8814c..12fd41af0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -27,7 +27,7 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 备选方案 -**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.events[seq]`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 +**改为投影 `measure().totalTokens`。** 测量服务本来就合成了正是这个量(`baseline` 锚点加有符号的 `surfaceDeltaTokens`),而且反应正确——同一次压缩前后实测为 4383 → 304。但它是一个建立在私有重放状态上的服务,不是纯折叠,投影无法调用它。要在 `ProjectionDefinition` 内部复现它的锚点,需要 `_estimateProviderAssistant` 对按 seq 引用的分片事件进行随机访问(`session.eventAt(seq)`),而 `apply(state, event)` 拿不到。以取样时的表层总量作为锚点,是同一个思路在纯逐事件折叠中可达的版本。 **在压缩结束时补写一条合成的用量记录。** 这确实能推动 `pressureTokens` 本身,但压缩手上唯一的用量是摘要请求自己的用量——那是完全另一个提示词。把它记成本对话的提示词规模,等于把谎言写进持久日志,而不只是写进某一处展示。 diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml index f125c62af7..b56a2c78ac 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.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-context-form-vocabulary.md -2026-08-05-context-form-vocabulary.md: 9f20614dcd2ed0164efb51f938de9f74657d3bc3 -2026-08-05-context-form-vocabulary.zh.md: ebdabeab5a074f21e8fac9625a78c99cc401a6d3 +2026-08-05-context-form-vocabulary.md: c4f1414ad90ddefbac2d216f9109c95726769203 +2026-08-05-context-form-vocabulary.zh.md: 57af2be2b25ce9af36d7551fb271ef577e0f7b4c diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md index 9f20614dcd..c4f1414ad9 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.md @@ -39,7 +39,7 @@ That move also relocates catalog **identity**: the republish digest now covers t Both readers are **all-or-nothing**: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared. -The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.events`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. +The producer side validates the same durable data with the same posture. `catalogHistory` reads `source.entries` out of `agent.session.snapshotEvents()`, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty `kind` — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point. Everything else — including a form this UI version does not present, a form absent from the source, and a `catalog` whose entries are unusable — renders the **opaque** body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer. diff --git a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md index ebdabeab5a..57af2be2b2 100644 --- a/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-context-form-vocabulary.zh.md @@ -39,7 +39,7 @@ Status: implemented 两个读取器都是**全有或全无**:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。 -生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.events` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 +生产方一侧对同一份持久数据采取同样的姿态。`catalogHistory` 从 `agent.session.snapshotEvents()` 读 `source.entries`,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 `kind` 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。 其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 `catalog`——一律渲染 **opaque** 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index ad9994bc34..72e8ad9a15 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: ce07e63681a659fb9bf758a24301c531f3f27e39 -2026-08-05-feedback-gated-session-telemetry.zh.md: 9657c7dc9243377233f20b265569829b06162fb5 +2026-08-05-feedback-gated-session-telemetry.md: 9dab135029bd4a5dd209157b48b6f7dca4040224 +2026-08-05-feedback-gated-session-telemetry.zh.md: 824aa1a04f3854163fdd16f4db7a7593540976e0 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index ce07e63681..9dab135029 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -18,7 +18,7 @@ Session telemetry originally has one mounted behavior: every accepted record ent The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. -Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when the exact `feedback/record` object is already stored at `session.events[event.seq]`. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. +Mode resolution is a closed, fail-before-setup check: an unknown direct-construction value fails before transport configuration is read. Only `FULL` exposes the public service's `emit()` path to the SDK pipeline. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability; its listener passes an event to `captureSession()` only when `session.eventAt(event.seq)` returns that exact `feedback/record` object. `Session.append` commits that object before publishing `session/event`, so replay includes the feedback but cannot extend past its boundary. `DISABLED` creates neither the capability nor the SDK pipeline and does not inspect exporter configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index 9657c7dc92..824aa1a04f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -18,7 +18,7 @@ Status: implemented 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.zh.md)说明了按需路径为何使用权威日志而非记录副本。 -模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是该事件与那个 `feedback/record` 对象身份完全相同,且该对象已存储于 `session.events[event.seq]`。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 +模式解析采用封闭式检查,并在设置前失败:通过直接构造传入未知值时,会在读取传输配置前失败。只有 `FULL` 向 SDK 流水线开放公共服务的 `emit()` 路径。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力;其监听器向 `captureSession()` 传递事件的唯一条件,是 `session.eventAt(event.seq)` 返回完全相同的 `feedback/record` 对象。`Session.append` 在发布 `session/event` 前已提交该对象,因此回放包含该反馈,但不会越过其边界。`DISABLED` 既不创建该能力,也不创建 SDK 流水线,并且不检查导出器配置。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index 7ce81010a9..fded384582 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md -2026-07-06-recallable-compaction.md: 7309297af84a43a29e03feae815ac8c937a9da6a -2026-07-06-recallable-compaction.zh.md: 890493ae7d6a7b9066dd071b2cc8f642e9c84c86 +2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448 +2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index 7309297af8..f0cf1b0602 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor. - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint. -Both read `exec.agent.session.events` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. +Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. ### Cache and cost diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index 890493ae7d..8e8793f28b 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -46,7 +46,7 @@ Status: proposed - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 -两个工具都读取 `exec.agent.session.events`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 +两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 ### 缓存与成本 diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index 42b87eff8a..7f8e09c6c5 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - const summary = finalText([...agent.session.events]).toLowerCase() + const summary = finalText([...agent.session.snapshotEvents()]).toLowerCase() expect(summary.length).toBeGreaterThan(0) // …and the world agrees: the test passes when WE run it, and the test diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 03de26d878..52889ce645 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // A compaction ran: the start…end bracket landed in the real log. const starts = events.filter(e => e.type === 'compaction/start') diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index a8825cc84d..22ca1e1976 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) expect(calls.some(event => event.data.name === 'bash')).toBe(true) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 0f076fd30e..2b0306fe7f 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr + 'and return only the joined string.', }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events: SessionEvent[] = [...agent.session.events] + const events: SessionEvent[] = [...agent.session.snapshotEvents()] // The wire contract: every request this session made offered EXACTLY ONE // tool — run_code (the logged header snapshots the assembled list). @@ -418,11 +418,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - const events: SessionEvent[] = [...handle.agent.session.events] + const events: SessionEvent[] = [...handle.agent.session.snapshotEvents()] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { - const splice = handle.agent.session.events.findLast(event => event.type === 'agent/inbox/spliced' + const splice = handle.agent.session.snapshotEvents().findLast(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions')) const inserted = splice?.type === 'agent/inbox/spliced' ? splice.data.inserted.find(message => message.source.kind === 'agent-instructions') diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index 6767357b4e..fc4e815d2e 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. - expect(finalText([...resumed.session.events])).toContain(SECRET) + expect(finalText([...resumed.session.snapshotEvents()])).toContain(SECRET) }, 180_000) }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index 014f1e3e69..eaff804410 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // The model actually called the tool. const calls = events.filter(event => event.type === 'tool/call') diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 8e4a5769ef..5e58d868c5 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -330,7 +330,7 @@ describe('web e2e: continuous conversation grown through the composer', () => { } if (sessionId === undefined) throw new Error('continuous conversation completed no turn') - expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => ( + expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => ( event.type === 'turn/end' && event.data.reason.kind === 'completed' ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index 5e3c02a436..54b1df5bd3 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -181,13 +181,13 @@ describe('web e2e: long Chat interaction contract', () => { const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN) const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1) const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2) - const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker) - const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker) + const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker) + const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) - const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker) - const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker) - const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( + const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker) + const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker) + const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`) @@ -311,9 +311,9 @@ describe('web e2e: long Chat interaction contract', () => { .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) if (child === undefined) throw new Error('message branch did not create a child session') expect(child.session.header.seedLength).toBe(boundary.seq + 1) - expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) - expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) + expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').last() @@ -330,11 +330,11 @@ describe('web e2e: long Chat interaction contract', () => { await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) expect(await composer.textContent()).toBe('') expect(await composer.isEnabled()).toBe(true) - expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) - expect(child.session.events.filter(event => ( + expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false) + expect(child.session.snapshotEvents().filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) - const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( + const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' }) diff --git a/apps/web/tests/chat-scroll-fixture.ts b/apps/web/tests/chat-scroll-fixture.ts index ed6c80da68..5f59aa48e5 100644 --- a/apps/web/tests/chat-scroll-fixture.ts +++ b/apps/web/tests/chat-scroll-fixture.ts @@ -164,7 +164,7 @@ function fixtureLog(session: Session): string { cwd: '{{cwd}}', delegationDepth: 0, }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/complex-history.perf.ts b/apps/web/tests/complex-history.perf.ts index c43b919e5d..564334b1f3 100644 --- a/apps/web/tests/complex-history.perf.ts +++ b/apps/web/tests/complex-history.perf.ts @@ -313,7 +313,7 @@ function fixtureLog(session: Session): string { } return [ JSON.stringify(header), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 9f4ad2fc53..8df3a04fb0 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -109,7 +109,7 @@ describe('web e2e: /goal human transcript presentation', () => { const sessions = scaffold.ctx.sessions.list() expect(sessions).toHaveLength(1) - const persisted = sessions[0]?.events ?? [] + const persisted = sessions[0]?.snapshotEvents() ?? [] expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => event.type)).toEqual(['command/run', 'command/done']) expect(persisted.some(event => event.type === 'user/message')).toBe(false) diff --git a/apps/web/tests/markdown-cjk-strong.e2e.ts b/apps/web/tests/markdown-cjk-strong.e2e.ts index b99f7f90d4..da82648c50 100644 --- a/apps/web/tests/markdown-cjk-strong.e2e.ts +++ b/apps/web/tests/markdown-cjk-strong.e2e.ts @@ -76,7 +76,7 @@ function markdownFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54783b48a5..a888469339 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -131,7 +131,7 @@ function markdownImageFixture(remoteUrl: string): string { // the stats line renders its LLM segment only while the step's measured // milliseconds exceed zero, so a fixture that leaves the times unset lets // the replay's own speed decide whether the golden matches. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index be3e30a380..d1da29922a 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -73,7 +73,7 @@ function markdownFixture(linkUrl: string): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 77e583bf45..28e98a89a6 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -139,7 +139,7 @@ function wideTableFixture(): string { return [ JSON.stringify(header), // Spaced event times, as the sibling markdown fixtures pin them. - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index c5e3e22a60..020d4b2a3a 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -77,7 +77,7 @@ function mathFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index b63e155d9d..19f829d5ac 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -72,7 +72,7 @@ describe('minimal agent preset', () => { it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { const requestHeader = agentHandle.agent.session.requestHeader() if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') - expect(agentHandle.agent.session.events.some(event => event.type === 'user/message' + expect(agentHandle.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt')).toBe(false) const presetFileSystem = scaffold.ctx.agentPresets.serviceFor(agentHandle.agent, 'fs') diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts index 9e1772cdce..ca8fafc63e 100644 --- a/apps/web/tests/produced-file-mentions.e2e.ts +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -107,7 +107,7 @@ function mentionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 0761639de5..5494a934e3 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -96,7 +96,7 @@ function producedFixture(): string { type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify({ + ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: eventTimeOrigin + event.seq * 1_000, })), '', diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ad7e5410da..b8aa62fe5f 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -58,7 +58,7 @@ function sourceSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } @@ -105,7 +105,7 @@ function targetSessionFixture(): string { createdAt: 0, cwd: '{{cwd}}', }), - ...session.events.map(event => JSON.stringify(event)), + ...session.snapshotEvents().map(event => JSON.stringify(event)), '', ].join('\n') } diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6a98024ec1..c3cbf5b32b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -804,7 +804,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise JSON.stringify(record)), + ...packChunkRuns(session.snapshotEvents()).map(record => JSON.stringify(record)), '', ].join('\n') } @@ -849,7 +849,7 @@ async function assertReplaySession( const userPrompts = fixtureUserPrompts(expected) const candidates = sessions.filter((session) => { if (session.header.parentSession !== undefined) return false - const actual = session.events.flatMap((event) => { + const actual = session.snapshotEvents().flatMap((event) => { if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') return text.length === 0 ? [] : [text] diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 5a15831128..81107f1bea 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -182,7 +182,7 @@ async function waitForReply( ): Promise> { const deadline = Date.now() + timeoutMs while (true) { - const event = handle.agent.session.events.find((candidate): candidate is SessionEvent<'assistant/message'> => ( + const event = handle.agent.session.snapshotEvents().find((candidate): candidate is SessionEvent<'assistant/message'> => ( candidate.type === 'assistant/message' && assistantText(candidate) === text )) if (event !== undefined) return event @@ -403,7 +403,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every')) const ids = new Set(everyRecords.map(record => record.id)) - const dispatches = everyHandle.agent.session.events.filter(event => ( + const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && ids.has(event.data.id) @@ -418,7 +418,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { const decision = acceptedAt[0] if (decision === undefined) throw new Error('missing Every decision time') - const batch = everyHandle.agent.session.events.find(event => ( + const batch = everyHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'schedule' @@ -441,7 +441,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { if (reminderRequest === undefined) throw new Error('model did not receive the Every batch') expect(requestText(reminderRequest)).toContain(batchBlock.text) expectReminderFraming(reminderRequest) - const active = foldScheduleEvents(everyHandle.agent.session.events).active + const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active expect(active).toHaveLength(2) expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true) @@ -463,7 +463,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { it('uses request-local browser context to create an explicit local At reminder', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at')) - const user = atHandle.agent.session.events.find(event => ( + const user = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'user/message' && event.data.source.kind === 'user' && event.data.content.some(block => block.type === 'text' && block.text === AT_USER_PROMPT) @@ -488,12 +488,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { } expect(selectedAt.time_zone).toBe(AT_BROWSER_ZONE) - const toolCall = atHandle.agent.session.events.find(event => ( + const toolCall = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'tool/call' && event.data.name === 'schedule_create' )) if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call') expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, at: selectedAt }) - const created = atHandle.agent.session.events.find(event => ( + const created = atHandle.agent.session.snapshotEvents().find(event => ( event.type === 'schedule/change' && event.data.operation === 'create' && event.data.schedule.kind === 'at' @@ -507,7 +507,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { prompt: AT_PROMPT, scheduledAt, }) - expect(atHandle.agent.session.events.filter(event => ( + expect(atHandle.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' && event.data.id === schedule.id diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 368beeb9e1..a55958353d 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -516,7 +516,7 @@ describe('web e2e: seeded history renders through cold resume', () => { const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const done = agent.session.events.filter(event => event.type === 'command/done').at(-1) + const done = agent.session.snapshotEvents().filter(event => event.type === 'command/done').at(-1) if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 96673df5f8..192fa31a7a 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -134,7 +134,7 @@ describe('web e2e: settings modal and General preferences', () => { it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) await page.getByRole('button', { name: '设置', exact: true }).click() @@ -150,11 +150,11 @@ describe('web e2e: settings modal and General preferences', () => { const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('permission:') expect(document).toContain('defaultPreset: read-only') - expect(existing.events.find(event => event.type === 'permission/preset')?.data) + expect(existing.snapshotEvents().find(event => event.type === 'permission/preset')?.data) .toEqual({ preset: 'workspace-write' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) - expect(created.events.map(event => [event.type, event.data])).toEqual([ + expect(created.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'read-only' }], ['sandbox/mode', { mode: 'read-only' }], ['approval/policy', { policy: 'ask' }], @@ -171,7 +171,7 @@ describe('web e2e: settings modal and General preferences', () => { const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(confirmedDocument).toContain('defaultPreset: danger-full-access') const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed')) - expect(confirmed.events.map(event => [event.type, event.data])).toEqual([ + expect(confirmed.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 6f2ca01325..24e642c83a 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -290,7 +290,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const child = scaffold.ctx.agents.get(childId) expect(child).toBeDefined() expect(child!.inbox.nextTurn).toHaveLength(2) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) // Only the waking send resumes the parked queue, FIFO, to settlement. diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 082bc0802b..54b8bd1cc6 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -158,8 +158,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(child).toBeDefined() expect(child!.status).toBe('idle') expect(child!.inbox.nextTurn).toHaveLength(1) - expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1) + expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const lastEnd = child!.session.snapshotEvents().filter(event => event.type === 'turn/end').at(-1) expect((lastEnd)?.data.reason.kind).toBe('aborted') // Only an explicit waking send resumes the parked queue, FIFO, then the diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f459ae1fd9..47c6c79f0a 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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.md -session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 -session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 +session.md: c6c2395ba2535b1d180c82e63dc31a1ed6c1e087 +session.zh.md: df2bac57a4a847df62467ed772386eca24c02183 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f3c246f7a7..c6c2395ba2 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -377,8 +377,15 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: number; + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -399,12 +406,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -451,7 +466,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -573,7 +588,7 @@ The hook bridges' `hook/invoked` / `hook/result` pairs (from `@deepseek-ai/dsh-h ## Durability contract -What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. +What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's default packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.snapshotEvents()` always equals what a backend can persist. Adding an event type that carries non-serializable data, corrupts core execution nesting, or violates its owner's declared relation is a breaking change to the on-disk format. The backends that consume this contract are on [persistence.md](persistence.md). diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bc706a11e9..df2bac57a4 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -379,8 +379,15 @@ declare class Session { * When this lifecycle appends the marker, it occupies this seq before the * store attaches and therefore does not publish either. Otherwise this seq * holds an ordinary published write. - */ + */ readonly firstLiveSeq: number; + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -401,12 +408,20 @@ declare class Session { */ static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session; /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[]; + eventAt(seq: number): SessionEvent | undefined; + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[]; /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ get seq(): number; /** @@ -453,7 +468,7 @@ declare class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -577,7 +592,7 @@ interface TurnEndReasonMap { ## 持久性约定 -持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.events` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 +持久化后端依赖的约定如下:持久日志无损保存每个事件,**包括** `assistant/chunk`;`seq` 必须连续,因此不能从规范日志中过滤分片。后端可以为事件批次选择自己的存储编码,只要 `load` 返回与追加时完全一致的事件即可(JSONL 后端默认启用的打包分片行就是此类编码;见 [persistence.md](persistence.zh.md))。所有 `event.data` 都必须可序列化为 JSON;`Session.append` 会从源头强制这一要求(遇到不可序列化数据时抛出),因此错误事件绝不会进入日志,`session.snapshotEvents()` 始终与后端可持久化的内容一致。新增会携带不可序列化数据、破坏核心执行嵌套或违反事件所有方声明关系的事件类型,都会构成磁盘格式的破坏性变更。 消费此约定的后端见 [persistence.md](persistence.zh.md)。 diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 99cd3695fc..7ac71f1ea0 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -71,7 +71,7 @@ describe('ACP machine permission policy', () => { const foreign = { session: { id: request.agent.session.id, - events: [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + snapshotEvents: () => [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], append: () => ({}), }, } as unknown as Agent diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 85125b3661..55e56a25b0 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -882,7 +882,7 @@ describe('automation-only ACP bridge', () => { expect(secondImage.attachment.mediaType).toBe('image/jpeg') expect(secondImage.attachment.bytes).toBe(1) const agent = harness.ctx.agents.get(SessionId(sessionId)) - expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==') + expect(JSON.stringify(agent?.session.snapshotEvents())).not.toContain('AQ==') }) it('rejects a malformed image batch atomically and frees the prompt slot', async () => { @@ -953,7 +953,7 @@ describe('automation-only ACP bridge', () => { sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], })).rejects.toThrow(/inline image prompts were not advertised/) - expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('renders baseline resource links as textual references in the user message', async () => { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 8a44856590..c44ee3ab2e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -216,7 +216,7 @@ describe('ACP prompt lifecycle', () => { const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) .finally(() => { settled = true }) await vi.waitFor(() => { - expect(agent.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(agent.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.length > 0)).toHaveLength(2) }) expect(settled).toBe(false) @@ -316,7 +316,7 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) expect(harness.adapter.requests).toEqual([]) - const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? [] + const events = harness.ctx.agents.get(SessionId(sessionId))?.session.snapshotEvents() ?? [] expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) }) @@ -443,7 +443,7 @@ describe('ACP prompt lifecycle', () => { await harness.client.cancel({ sessionId }) await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -468,13 +468,13 @@ describe('ACP prompt lifecycle', () => { source: { kind: 'plugin', plugin: 'test' }, })) await vi.waitFor(() => { - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) await harness.client.cancel({ sessionId }) await agent.whenIdle() - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index fd95221825..352178c781 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -197,8 +197,8 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { scopedResult: scopedResult.value, rootGoal: host.goals.get(rootAgent)?.objective, scopedGoal: host.goals.get(scopedAgent)?.objective, - rootEvents: rootAgent.session.events.length, - scopedEvents: scopedAgent.session.events.length, + rootEvents: rootAgent.session.snapshotEvents().length, + scopedEvents: scopedAgent.session.snapshotEvents().length, } await client.fiber.dispose() diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..efbf02d60f 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -484,7 +484,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return { id: attached.id, header: attached.header, events: [...attached.events] } + return { id: attached.id, header: attached.header, events: [...attached.snapshotEvents()] } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index d78509e49b..1a8fa061b6 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -112,7 +112,7 @@ export class SessionHistoryController { // Constructor seed events have no session/event notification. Normally // only the end-seed suffix is new; if persistence advanced after the // opening observation, replay everything beyond that snapshot cursor. - const suffix = session.events.slice(snapshotCursor === undefined + const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq : snapshotCursor + 1) buffered.unshift(...suffix) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 342dd977eb..b18d013711 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -194,7 +194,7 @@ export class SessionController extends TypertRemoteService { ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return Promise.resolve({ meta: attached.header, events: [...attached.events] }) + return Promise.resolve({ meta: attached.header, events: [...attached.snapshotEvents()] }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3f4e721228..c5c46aa0e8 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -378,7 +378,13 @@ describe('ApiSession create or adoption', () => { } as never) const resumed = { id: meta.id, - session: { id: meta.id, header: meta, events }, + session: { + id: meta.id, + header: meta, + snapshotEvents: () => events, + eventAt: (seq: number) => events[seq], + seq: events.length, + }, status: 'idle', ctx, } as unknown as Agent diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7156ac569d..07d08d1a3c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -326,7 +326,7 @@ describe('attached updatedAt tracks human prompts', () => { meta: { cwd: '/proj', createdAt: 500 }, }) ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) - const boundary = resumed.events.at(-1) + const boundary = resumed.snapshotEvents().at(-1) expect(boundary?.type).toBe('session/end-seed') expect(boundary?.time).toBeGreaterThan(worked) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 06e7b5a3dd..c7fad68546 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -92,7 +92,7 @@ describe('sessions.fork', () => { expect(response.ok).toBe(true) if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) - expect(child?.events.map(event => event.type)).toEqual([ + expect(child?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'session/end-seed', ]) expect(child?.header.parentSession).toBe(source.id) @@ -198,13 +198,13 @@ describe('sessions.fork', () => { const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { - expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 })) expect(pastEnd.ok).toBe(true) if (pastEnd.ok) { - expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type)) + expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type)) .toEqual(expectedTypes) } await ctx.fiber.dispose() @@ -227,11 +227,11 @@ describe('sessions.fork', () => { const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') // What a stopped message's fork button anchors on: the frozen node sits // one event before its turn/end, floored client-side to that event's seq. - const anchor = (source.events.at(-1)?.seq ?? 0) - 1 + const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok).toBe(true) if (!response.ok) return - expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([ + expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', 'turn/start', 'user/message', 'turn/end', 'session/end-seed', @@ -242,7 +242,7 @@ describe('sessions.fork', () => { it('rejects an in-log anchor whose turn is still open', async () => { const ctx = await composed() const source = liveAgent(ctx, 'session-open', 1, 'open') - const anchor = source.events.at(-1)?.seq ?? 0 + const anchor = source.snapshotEvents().at(-1)?.seq ?? 0 const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response).toMatchObject({ ok: false, diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index d42639922e..90b5851e08 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -146,7 +146,7 @@ describe('Session history raw journal', () => { value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { @@ -366,7 +366,7 @@ describe('Session history raw journal', () => { await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'event', event: { type: 'turn/end' } }, }) - const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + const events = vi.spyOn(session, 'snapshotEvents').mockImplementation(() => { throw new Error('live result rescanned Session history') }) try { 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..db04b3a6a2 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -318,7 +318,7 @@ describe('session.history projections block', () => { expect('test/last-user' in after.projections.values).toBe(false) expect(after.projections.values.sessionListMetadata).toEqual({ blank: true, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) }) @@ -352,7 +352,7 @@ describe('session.list projections column', () => { expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) expect(row?.projections?.values.sessionListMetadata).toEqual({ blank: false, - lastPromptAt: session.events.at(-1)?.time, + lastPromptAt: session.eventAt(session.seq - 1)?.time, }) expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index b1c54602e5..4a98ce2626 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -76,7 +76,7 @@ describe('sessions.rename', () => { expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') - const event = source.events.findLast(item => item.type === 'session/title') + const event = source.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.seq).toBe(renamed.value.seq) expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) }) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 8461d99a24..df97c60953 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -33,6 +33,16 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent { } as SessionEvent } +function eventSession(header: SessionHeader, events: readonly SessionEvent[]): Session { + return { + id: header.id, + header, + seq: events.length, + eventAt: (seq: number) => events[seq], + snapshotEvents: (fromSeq = 0, toSeqExclusive = events.length) => events.slice(fromSeq, toSeqExclusive), + } as unknown as Session +} + function cold( ctx: Context, header: SessionHeader, @@ -163,12 +173,10 @@ describe('SessionHistoryController', () => { [Symbol.asyncIterator]() const opening = iterator.next() - ctx.emit('session/event', { - id: SessionId('unrelated'), events: [event('fixture/other', 0)], - } as unknown as Session, event('fixture/other', 0)) - ctx.emit('session/event', { - id: sessionId, events: [event('fixture/start', 0)], - } as unknown as Session, event('fixture/start', 0)) + const unrelated = event('fixture/other', 0) + const start = event('fixture/start', 0) + ctx.emit('session/event', eventSession({ ...header, id: SessionId('unrelated') }, [unrelated]), unrelated) + ctx.emit('session/event', eventSession(header, [start]), start) inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) @@ -194,7 +202,7 @@ describe('SessionHistoryController', () => { observed.resolve({ source: 'live', header: attached.header, - events: attached.events, + events: attached.snapshotEvents(), cursor: attached.seq - 1, projections: { asOfSeq: attached.seq - 1, values: {} }, retain: vi.fn(), @@ -292,10 +300,7 @@ describe('SessionHistoryController', () => { await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) const skipped = event('fixture/skipped', 1) const gap = event('fixture/gap', 2) - live.ctx.emit('session/event', { - id: session.id, - events: [event('fixture/start', 0), skipped, gap], - } as unknown as Session, gap) + live.ctx.emit('session/event', eventSession(session.header, [event('fixture/start', 0), skipped, gap]), gap) await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) }) diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index b5b2839b00..417c66510a 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -16,7 +16,7 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' // Empty type imports carry the loader Context merge for the settlement await // and the cmdline Context merge for the appExit host value. import type {} from '@deepseek-ai/cordis-plugin-loader' @@ -59,12 +59,16 @@ export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stde } /** Aggregate the last assistant text and turn outcome in one owned interval. */ -function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome { +function summarize(session: Session, firstSeq: number): RunOutcome { let started = false let text = '' let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined - for (const event of events) { - if (event.seq < firstSeq) continue + const length = session.seq + for (let seq = firstSeq; seq < length; seq++) { + const event = session.eventAt(seq) + if (event === undefined) { + throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) + } if (event.type === 'turn/start') { started = true continue @@ -196,7 +200,7 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { stopReasoning() } await sessions.flush(agent.session) - const outcome = summarize(agent.session.events, firstSeq) + const outcome = summarize(agent.session, firstSeq) io.stdout.write(outcome.text + '\n') if (outcome.reason?.kind === 'error') { io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`) diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index 97229590b7..7cc9d3331e 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -121,7 +121,7 @@ function expectLastLifecycle( args: string, outcome: CommandResult, ): string { - const lifecycle = test.agent.session.events + const lifecycle = test.agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .slice(-2) const runEvent = lifecycle[0] diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 95358b0036..e4cbedd42a 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -130,7 +130,7 @@ describe('command-compact real Loader composition', () => { text: 'Compacted 3 history items (~99 tokens).', sourceEventSeq: RESULT.summarySeq, }) - expect(session.events.map(event => ({ type: event.type, data: event.data }))).toEqual([ + expect(session.snapshotEvents().map(event => ({ type: event.type, data: event.data }))).toEqual([ { type: 'command/run', data: { diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index f5fba599a4..b609f949c8 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -162,7 +162,7 @@ export async function compactSurfaceRegion( ): Promise { if (options.owner === null) signal?.throwIfAborted() const selection = validateSurfaceRegion(session, start, end) - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -305,7 +305,7 @@ function assertCompactionInactive( * @param stage - operation label included in the busy diagnostic. */ export function assertNoActiveCompaction(session: Session, stage: string): void { - const entryState = inspectCompactionEntryState(session.events) + const entryState = inspectCompactionEntryState(session) assertCompactionInactive( entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, @@ -510,11 +510,10 @@ function buildSummarizationInput( shadowedSeqs: readonly number[], ): SummarizationInput { const header = session.requestHeader() - const events = session.events const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. // oxlint-disable-next-line typescript/no-non-null-assertion - .map(seq => session.deriveEventMessage(events[seq]!)) + .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { ...header?.system === undefined ? {} : { system: header.system }, @@ -524,15 +523,15 @@ function buildSummarizationInput( } /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */ -function inspectCompactionEntryState(events: readonly SessionEvent[]): CompactionEntryState { +function inspectCompactionEntryState(session: Session): CompactionEntryState { let openTurn: number | null = null let openTurnStateKnown = false let unmatchedCompactionStart: SessionEvent<'compaction/start'> | undefined let compactionEntryStateKnown = false let latestEndSeedSeq: number | undefined - for (let index = events.length - 1; index >= 0; index -= 1) { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + const event = session.eventAt(seq)! if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') { latestEndSeedSeq = event.seq } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 82eadaa71e..89e62c0db5 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -609,7 +609,7 @@ describe('pressure measurement and retention', () => { await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() expect(session.surface.replaceGeneration).toBe(generation) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('does nothing below threshold and compacts a priced head above threshold', async () => { @@ -837,10 +837,10 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - const original = session.events.find(event => event.type === 'tool/result') + const original = session.snapshotEvents().find(event => event.type === 'tool/result') expect(original?.type === 'tool/result' && original.data.message.content[0].content[0]) .toEqual({ type: 'text', text: 'X'.repeat(3_000) }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0) }) }) @@ -866,7 +866,7 @@ describe('compaction region transaction', () => { expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') - const summary = session.events.findLast(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, shadowedTokenCount: result.shadowedTokenCount, @@ -882,7 +882,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - const replay = Session.create(SessionId('replay'), [...session.events]) + const replay = Session.create(SessionId('replay'), [...session.snapshotEvents()]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) @@ -1012,7 +1012,7 @@ describe('compaction region transaction', () => { agent(session, MODEL), )).rejects.toThrow('summary unavailable') expect(session.surface.nodes).toEqual(before) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable' }) }) @@ -1026,7 +1026,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toBe('plain failure') - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'plain failure' }) }) @@ -1046,7 +1046,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) }) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) }) it('rejects concurrent surface appends before committing the replacement', async () => { @@ -1065,7 +1065,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/session surface changed/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('rejects a non-shrinking framed summary under the conversation meter', async () => { @@ -1082,7 +1082,7 @@ describe('compaction region transaction', () => { nodes[2]!, agent(session, MODEL), )).rejects.toThrow(/summary is not smaller/) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('lets a model-independent custom summarizer compact without a conversation model', async () => { @@ -1333,7 +1333,7 @@ describe('default one-shot summarizer', () => { const session = conversation(3, 'large history '.repeat(500)) const nodes = session.surface.nodes await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) - expect(session.events.findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], llmStreamCall: true, provider: 'routed-summary-provider', @@ -1459,7 +1459,7 @@ describe('automatic listener and loader composition', () => { next: () => Promise = () => Promise.resolve(undefined), ): Promise { const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' } - const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 + const turn = owner.session.snapshotEvents().findLast(event => event.type === 'turn/start')?.data.turn ?? 1 return agentEvents(ctx, owner).waterfall( 'agent/request-error', { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal }, @@ -1479,11 +1479,11 @@ describe('automatic listener and loader composition', () => { }) const pressured = conversation(4) await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) - expect(pressured.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) const small = conversation(1) await preStep(ctx, agent(small, MODEL)) - expect(small.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(small.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) @@ -1500,7 +1500,7 @@ describe('automatic listener and loader composition', () => { .resolves.toEqual({ kind: 'enter', messages: [] }) expect(compactIfNeeded).not.toHaveBeenCalled() - expect(pressured.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(pressured.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('warns and continues after operational failures, including non-Errors', async () => { @@ -1516,7 +1516,7 @@ describe('automatic listener and loader composition', () => { await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] }) expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('warns once per routed target when proactive pressure has no context metadata', async () => { @@ -1575,7 +1575,7 @@ describe('automatic listener and loader composition', () => { expect(decision).toBe(true) expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(session.surface.nodes).toContain(retainedSeq) }) @@ -1594,7 +1594,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) expect(compact.calls).toHaveLength(0) }) @@ -1612,7 +1612,7 @@ describe('automatic listener and loader composition', () => { const session = toolConversation() expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) expect(compact.calls).toHaveLength(1) expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) @@ -1635,8 +1635,8 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true) expect(session.surface.replaceGeneration).toBe(1) - expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2) - expect(session.events.findLast(event => event.type === 'compaction/end')?.data) + expect(session.snapshotEvents().filter(event => event.type === 'tool/result')).toHaveLength(2) + expect(session.snapshotEvents().findLast(event => event.type === 'compaction/end')?.data) .toMatchObject({ error: 'summary unavailable after prune' }) expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface')) }) @@ -1838,10 +1838,10 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - const summaries = session.events.filter(event => event.type === 'compaction/summary').length + const summaries = session.snapshotEvents().filter(event => event.type === 'compaction/summary').length expect(summaries).toBe(1) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) - expect(session.events.filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) + expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toHaveLength(summaries) }) it('auto:false installs neither automatic listener', async () => { @@ -1853,7 +1853,7 @@ describe('automatic listener and loader composition', () => { }) const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) @@ -1885,7 +1885,7 @@ describe('automatic listener and loader composition', () => { const session = conversation(4) await preStep(ctx, agent(session, MODEL)) - expect(session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false) }) }) @@ -2025,7 +2025,7 @@ describe('route-priced image pressure', () => { const result = await compact.compactIfNeeded(agent(session), 'pressure', SIGNAL) expect(result).not.toBeNull() - const summaryEvent = session.events.find(event => event.type === 'compaction/summary') + const summaryEvent = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summaryEvent).toBeDefined() const shadowedHeuristic = before.nodes .filter(node => result?.shadowedSeqs.includes(node.seq)) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 1dacfe2114..7d44f6e268 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - return [...session.events] + return [...session.snapshotEvents()] } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { @@ -233,8 +233,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () await waitForIdle(ctx, agent) expect(agent.session.requestHeader()?.config.model).toBe('mock') - expect(agent.session.events.some(event => event.type === 'compaction/summary')).toBe(true) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() const precedingResult = events.findLast(event => @@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( (e): e is SurfaceEvent => @@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, )! @@ -419,11 +419,11 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data)) .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) .toEqual([3]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index a0497f8041..d5d958b3fc 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -234,8 +234,8 @@ function detachedService(): { ctx: Context; compact: GatedCompactionEngine; flus return { ctx, compact: new GatedCompactionEngine(ctx, { auto: false }), flushes: () => flushes } } -function compactEvents(session: Session): Array { - return session.events.filter(event => event.type.startsWith('compaction/')) +function compactEvents(session: Session): SessionEvent[] { + return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')) } describe('compactNow through the real loop', () => { @@ -296,14 +296,14 @@ describe('compactNow through the real loop', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - const start = agent.session.events.findLast(event => event.type === 'compaction/start') + const start = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/start') const injected = agent.inbox.nextStep.find(message => message.source.kind === 'plugin' && message.source.plugin === 'test') - const end = agent.session.events.findLast(event => event.type === 'compaction/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(injected).toBeDefined() expect(end).toBeDefined() - expect(agent.session.events.some(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.id === injected?.id)).toBe(false) agent.followup(createUserMessage({ @@ -335,7 +335,7 @@ describe('compactNow through the real loop', () => { expect(attempts).toEqual(['compaction/start', 'compaction/summary']) expect(result).not.toBeNull() expect(derivedText(agent.session)[0]).toContain('checkpoint') - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'listener')).toHaveLength(0) const types = compactEvents(agent.session).map(event => event.type) expect(types).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) @@ -355,7 +355,7 @@ describe('compactNow through the real loop', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'compaction/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'compaction/start')).toBe(false) }) it('releases turn admission after a summarizer failure and records the failed attempt', async () => { @@ -405,14 +405,14 @@ describe('compactNow transaction and failure classification', () => { expect(result).not.toBeNull() expect(result?.sourceCommandId).toBe(commandId) expect(flushes()).toBe(1) - expect(session.events.filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) - const start = session.events.findLast(event => event.type === 'compaction/start') - const summaryEvent = session.events.findLast(event => event.type === 'compaction/summary') - const checkpoint = session.events.findLast( + expect(session.snapshotEvents().filter(event => event.type === 'turn/start').at(-1)?.data.turn).toBe(7) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const summaryEvent = session.snapshotEvents().findLast(event => event.type === 'compaction/summary') + const checkpoint = session.snapshotEvents().findLast( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && isCompactCheckpointSource(event.data.source), ) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') const correlated = { compactionId: result?.compactionId, sourceCommandId: commandId } expect(start?.data).toEqual({ ...correlated, turn: null }) expect(summaryEvent?.data.sourceCommandId).toBe(commandId) @@ -442,9 +442,9 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - const reloaded = Session.create(SessionId('stale-orphan'), [...original.events]) - const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed') - const orphan = reloaded.events.find(event => event.type === 'compaction/start') + const reloaded = Session.create(SessionId('stale-orphan'), [...original.snapshotEvents()]) + const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') + const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) expect(boundary?.seq).toBeGreaterThan(orphan?.seq ?? Number.MAX_SAFE_INTEGER) @@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events]) + const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.snapshotEvents()]) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -564,7 +564,7 @@ describe('compactNow transaction and failure classification', () => { expect(session.surface.replaceGeneration).toBe(generation + 1) expect(session.surface.nodes).not.toContain(head) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'user/message' + expect(session.snapshotEvents().some(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source))).toBe(false) }) @@ -583,7 +583,7 @@ describe('compactNow transaction and failure classification', () => { expect(causeOf(error).message).toBe('boundary rejected') vi.restoreAllMocks() expect(flushes()).toBe(0) - expect(session.events.findLast(event => event.type.startsWith('compaction/'))?.type) + expect(session.snapshotEvents().findLast(event => event.type.startsWith('compaction/'))?.type) .toBe('compaction/summary') expect(compactEvents(session).filter(event => event.type === 'compaction/start')).toHaveLength(1) @@ -649,7 +649,7 @@ describe('compactNow transaction and failure classification', () => { vi.restoreAllMocks() expect(error.code).toBe('commit') expect(released).toBe(1) - const end = session.events.findLast(event => event.type === 'compaction/end') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.type === 'compaction/end' && end.data.error).toContain('summary record rejected') expect(end?.type === 'compaction/end' && end.data.turn).toBeNull() }) @@ -685,8 +685,8 @@ describe('compactNow transaction and failure classification', () => { const result = await compact.compactNow(agent, SIGNAL) expect(result).not.toBeNull() - expect(session.events.some(event => event.type === 'turn/start')).toBe(false) - expect(session.events.find(event => event.type === 'compaction/start')?.data) + expect(session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) + expect(session.snapshotEvents().find(event => event.type === 'compaction/start')?.data) .toEqual({ compactionId: result?.compactionId, turn: null }) }) @@ -698,9 +698,9 @@ describe('compactNow transaction and failure classification', () => { expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('persistence') vi.restoreAllMocks() - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(true) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(true) + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(end?.data).toEqual({ compactionId: start?.data.compactionId, turn: null }) }) @@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => { const reserve = vi.fn(() => testCase.release) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const agent = fakeAgent(testCase.session, reserve) - const before = [...testCase.session.events] + const before = [...testCase.session.snapshotEvents()] const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() controller.abort(reason) @@ -731,7 +731,7 @@ describe('compactNow transaction and failure classification', () => { expect(reserve).not.toHaveBeenCalled() expect(measure).not.toHaveBeenCalled() expect(compact.calls).toHaveLength(0) - expect(testCase.session.events).toEqual(before) + expect(testCase.session.snapshotEvents()).toEqual(before) vi.restoreAllMocks() } }) @@ -780,7 +780,7 @@ describe('compactNow transaction and failure classification', () => { await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason) expect(compactEvents(session).map(event => event.type)).toEqual(['compaction/start', 'compaction/end']) - expect(session.events.some(event => event.type === 'compaction/summary')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'compaction/summary')).toBe(false) }) it('waits for the durability checkpoint before cancellation wins and admission releases', async () => { @@ -824,7 +824,7 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const summary = session.events.find(event => event.type === 'compaction/summary') + const summary = session.snapshotEvents().find(event => event.type === 'compaction/summary') expect(summary?.type === 'compaction/summary' && summary.data.rawOutput).toEqual(compact.rawOutput) expect(summary?.type === 'compaction/summary' && summary.data.usage).toEqual(compact.usage) }) @@ -839,8 +839,8 @@ describe('compactNow transaction and failure classification', () => { await compact.compactNow(agent, SIGNAL) - const start = session.events.findLast(event => event.type === 'compaction/start') - const end = session.events.findLast(event => event.type === 'compaction/end') + const start = session.snapshotEvents().findLast(event => event.type === 'compaction/start') + const end = session.snapshotEvents().findLast(event => event.type === 'compaction/end') expect(start).toBeDefined() expect(end).toBeDefined() expect(end!.time - start!.time).toBeGreaterThan(0) diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 7e261e15fa..d3f3212c1e 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -136,7 +136,7 @@ export class ToolResultPruner extends Service { pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] for (const seq of [...session.surface.nodes]) { - const event = session.events[seq] + const event = session.eventAt(seq) /* v8 ignore next -- surface seqs are validated contiguous log references. */ if (event?.type === 'tool/result') candidates.push({ seq, event }) } diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index e21a3635a7..1235b8fa2c 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -185,8 +185,8 @@ describe('ToolResultPruner session transaction', () => { expect(entry).toMatchObject({ originalSeq, callId: ToolCallId('one'), charsBefore: 100 }) expect(entry.charsAfter).toBeLessThanOrEqual(50) - const original = session.events[originalSeq]! - const replacement = session.events[entry.replacementSeq]! as SurfaceEvent + const original = session.snapshotEvents()[originalSeq]! + const replacement = session.snapshotEvents()[entry.replacementSeq]! as SurfaceEvent expect(original).toMatchObject({ type: 'tool/result', data: { @@ -219,7 +219,7 @@ describe('ToolResultPruner session transaction', () => { // Shadow-price protocol: the metering event sits directly before the // replacement and prices the shadowed node with the shared estimator. if (original.type !== 'tool/result') throw new Error('original is not a tool/result') - expect(session.events[entry.replacementSeq - 1]).toMatchObject({ + expect(session.snapshotEvents()[entry.replacementSeq - 1]).toMatchObject({ type: 'compaction/prune', data: { shadowedRange: { start: originalSeq, end: originalSeq }, @@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - const replay = Session.create(session.id, [...session.events]) + const replay = Session.create(session.id, [...session.snapshotEvents()]) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index 7252fb06b0..df82f348ee 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -251,8 +251,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): SessionTrace => { const trace: SessionTrace = { openTurn: null, compaction: undefined } traces.set(session, trace) - const staleOrphanStartSeqs = inheritedOrphanStartSeqs(session.events) - for (const event of session.events) { + const events = session.snapshotEvents() + const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) + for (const event of events) { // Constructor-seed repair boundaries can precede the end-seed marker // that proves an inherited orphan stale. Replay that inherited prefix // without letting the soon-to-be-cleared bracket veto its repair. diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index 72ebb4dad6..c492d53a70 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -37,15 +37,6 @@ function eventDelta(event: SessionEvent): number { } } -/** Read and validate the event named by a surface sequence. */ -function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent { - const event = events[seq] - if (event === undefined || event.seq !== seq) { - throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) - } - return event -} - /** Fold surface sequences not yet in the cache into its balance state. */ function extendCache( session: Session, @@ -56,11 +47,14 @@ function extendCache( const tail = seqs.slice(processed) // Validate the unseen tail before mutating the live cache, so a corrupt // append cannot leave a partially advanced state behind. - const events = session.events const pendingCuts: boolean[] = [] let inProgressToolCalls = cache.inProgressToolCalls for (const seq of tail) { - inProgressToolCalls += eventDelta(eventForSeq(events, seq)) + const event = session.eventAt(seq) + if (event === undefined || event.seq !== seq) { + throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) + } + inProgressToolCalls += eventDelta(event) if (inProgressToolCalls < 0) { throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`) } diff --git a/packages/compaction/compaction/tests/compaction.spec.ts b/packages/compaction/compaction/tests/compaction.spec.ts index 220dff5648..8b02f5f811 100644 --- a/packages/compaction/compaction/tests/compaction.spec.ts +++ b/packages/compaction/compaction/tests/compaction.spec.ts @@ -130,7 +130,7 @@ describe('CompactionEngine seam', () => { const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm')) - const startEvent = session.events.find(e => e.type === 'compaction/start') + const startEvent = session.snapshotEvents().find(e => e.type === 'compaction/start') expect(startEvent).toBeDefined() // Log-only: the compiler rejects surfaceOp on compaction/* (not a SurfaceEventType); // verify the runtime value is absent. @@ -141,13 +141,13 @@ describe('CompactionEngine seam', () => { expect(result.endSeq).toBeGreaterThan(result.summarySeq) expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq }) expect(result.shadowedSeqs).toEqual([original.seq]) - const checkpoint = session.events.find(event => event.type === 'user/message' + const checkpoint = session.snapshotEvents().find(event => event.type === 'user/message' && isCompactCheckpointSource(event.data.source)) expect(checkpoint?.type === 'user/message' && checkpoint.data.source) .toEqual(compactCheckpointSource(result.compactionId)) expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false) expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false) - expect(session.events.filter(e => e.type.startsWith('compaction/')).map(e => e.type)) + expect(session.snapshotEvents().filter(e => e.type.startsWith('compaction/')).map(e => e.type)) .toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) }) diff --git a/packages/compaction/compaction/tests/invariant.spec.ts b/packages/compaction/compaction/tests/invariant.spec.ts index 9a1165ab57..1aa8682e6a 100644 --- a/packages/compaction/compaction/tests/invariant.spec.ts +++ b/packages/compaction/compaction/tests/invariant.spec.ts @@ -68,9 +68,9 @@ describe('compaction invariants', () => { const source = Session.create(SessionId('stale-compaction-source')) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: null }) const replayed = ctx.sessions.create(SessionId('stale-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -89,9 +89,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('compaction/start', { compactionId: TEST_COMPACTION_ID, turn: 1 }) const replayed = ctx.sessions.create(SessionId('stale-numbered-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)) + expect(replayed.snapshotEvents().map(event => event.type)) .toEqual(['turn/start', 'compaction/start', 'session/end-seed']) await ctx.plugin(InvariantRegistry) @@ -111,9 +111,9 @@ describe('compaction invariants', () => { startTurn(source) source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) const replayed = ctx.sessions.create(SessionId('stale-repaired-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.map(event => event.type)).toEqual([ + expect(replayed.snapshotEvents().map(event => event.type)).toEqual([ 'compaction/start', 'turn/start', 'turn/end', @@ -138,9 +138,9 @@ describe('compaction invariants', () => { source.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } }) source.append('compaction/end', { compactionId: TEST_COMPACTION_ID, turn: null, error: 'failed after crossing turn' }) const replayed = ctx.sessions.create(SessionId('closed-nested-compaction-replay'), { - seed: source.events, + seed: source.snapshotEvents(), }) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') await ctx.plugin(InvariantRegistry) await expect(ctx.plugin(CompactionInvariant).then(() => undefined)) diff --git a/packages/compaction/compaction/tests/tool-pairing.spec.ts b/packages/compaction/compaction/tests/tool-pairing.spec.ts index cd4e2a874f..8478f47edd 100644 --- a/packages/compaction/compaction/tests/tool-pairing.spec.ts +++ b/packages/compaction/compaction/tests/tool-pairing.spec.ts @@ -7,7 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' const SURFACE = { surfaceOp: 'append' as const } function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number { - return session.events.filter(event => event.type === type)[nth]!.seq + return session.snapshotEvents().filter(event => event.type === type)[nth]!.seq } function surfaceSeq(session: Session, seq: number): number { @@ -241,41 +241,31 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1, 2] let generation = 0 - let eventCollectionReads = 0 - let eventIndexReads = 0 - const trackedEvents = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1 - return Reflect.get(target, property, receiver) as unknown - }, - }) + let eventReads = 0 const surface = { get nodes() { return nodes }, get replaceGeneration() { return generation }, } const session = { surface, - get events() { - eventCollectionReads += 1 - return trackedEvents + eventAt(seq: number) { + eventReads += 1 + return events[seq] }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true) expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } }, }) expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true) - expect(eventCollectionReads).toBe(1) - expect(eventIndexReads).toBe(3) + expect(eventReads).toBe(3) events.push({ type: 'user/message', seq: 4, time: 4, @@ -286,8 +276,7 @@ describe('tool-pairing cache refresh', () => { }) nodes.push(4) expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true) - expect(eventCollectionReads).toBe(2) - expect(eventIndexReads).toBe(4) + expect(eventReads).toBe(4) events.push( { @@ -321,8 +310,7 @@ describe('tool-pairing cache refresh', () => { ) nodes.push(5, 6) expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true) - expect(eventCollectionReads).toBe(3) - expect(eventIndexReads).toBe(6) + expect(eventReads).toBe(6) events.push({ type: 'user/message', seq: 7, time: 7, @@ -334,8 +322,7 @@ describe('tool-pairing cache refresh', () => { nodes.splice(0, nodes.length, 7) generation += 1 expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true) - expect(eventCollectionReads).toBe(4) - expect(eventIndexReads).toBe(7) + expect(eventReads).toBe(7) }) it('rebuilds defensively when a same-generation surface entry count regresses', () => { @@ -355,7 +342,7 @@ describe('tool-pairing cache refresh', () => { ] const nodes: number[] = [0, 1] const session = { - events, + eventAt: (seq: number) => events[seq], surface: { nodes, replaceGeneration: 0 }, } as unknown as Session expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true) @@ -399,24 +386,24 @@ describe('tool-pairing corrupt surfaces', () => { it('throws when a current surface seq has no matching event or indexes the wrong event', () => { const missingSeq = 1 const missing = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 0, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [missingSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/) const mismatchedSeq = 0 const mismatched = { - events: [{ + eventAt: (seq: number) => [{ type: 'user/message', seq: 99, time: 0, data: createUserMessage({ content: [], source: { kind: 'user' }, }), surfaceOp: 'append', - } satisfies SessionEvent], + } satisfies SessionEvent][seq], surface: { nodes: [mismatchedSeq], replaceGeneration: 0 }, } as unknown as Session expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/) diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index ace82c24b2..f614926241 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -53,7 +53,7 @@ function visibleBaselineSource( } } for (const seq of agent.session.surface.nodes.toReversed()) { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) if (event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) return event.data.source @@ -228,7 +228,7 @@ export function apply(ctx: Context, config: Config): void { const alreadySupplied = desired !== undefined && ( claimed.some(message => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => { - const event = agent.session.events[seq] + const event = agent.session.eventAt(seq) return event?.type === 'user/message' && sameContextPayload(event.data, desired) }) ) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 30e700eda5..ce0118a569 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,7 +139,7 @@ function visibleInstructionChanges( ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.events.entries()) { + for (const [seq, event] of agent.session.snapshotEvents().entries()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 7f468bb6c8..a5cb2ae94c 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(PROBE) + expect(finalText([...live.agent.session.snapshotEvents()])).toContain(PROBE) }, 120_000) it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.events])).toContain(NESTED_PROBE) + expect(finalText([...live.agent.session.snapshotEvents()])).toContain(NESTED_PROBE) }, 120_000) it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { @@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - const events = [...live.agent.session.events] + const events = [...live.agent.session.snapshotEvents()] const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 5c9c3e7f2f..9855748a73 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -246,7 +246,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise + return agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) @@ -1096,7 +1096,7 @@ describe('workspace context request injection', () => { const second = await composeBaselinePrefix(ctx, agent) expect(second).toEqual(first) - expect(agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toHaveLength(1) expect(derivedText(agent)).toContain('repo rule') } finally { await rm(root, { recursive: true, force: true }) @@ -1115,14 +1115,14 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, [...original.session.events]) + const firstResume = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, [...firstResume.session.events]) + const secondResume = stubAgent(root, [...firstResume.session.snapshotEvents()]) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await rm(root, { recursive: true, force: true }) @@ -1144,11 +1144,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - expect(resumed.session.events.filter(event => event.type === 'user/message' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) } finally { await ctx.fiber.dispose() @@ -1170,13 +1170,13 @@ describe('workspace context request injection', () => { const original = stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, [...original.session.events]) + const firstResume = stubAgent(cwd, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, [...firstResume.session.events]) + const secondResume = stubAgent(cwd, [...firstResume.session.snapshotEvents()]) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) - expect(secondResume.session.events.filter(event => event.type === 'user/message' + expect(secondResume.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(1) expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md') expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root') @@ -1200,11 +1200,11 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, [...original.session.events]) + const resumed = stubAgent(cwd, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) - const update = resumed.session.events.findLast(event => event.type === 'user/message' + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions' @@ -1237,7 +1237,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1256,7 +1256,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1290,7 +1290,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, [...original.session.events]) + const claudeResume = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1305,7 +1305,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, [...claudeResume.session.events]) + const restored = stubAgent(root, [...claudeResume.session.snapshotEvents()]) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1340,7 +1340,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1354,7 +1354,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, [...resumed.session.events]) + const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1384,7 +1384,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( @@ -1400,7 +1400,7 @@ describe('workspace context request injection', () => { expect(decision.messages.map(message => message.id)).toEqual([inserted?.id]) expect(resumed.inbox.nextStep).toEqual([]) - expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(resumed.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'agent-instructions' && message.source.baseline === true))).toHaveLength(1) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1430,7 +1430,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( @@ -1483,7 +1483,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( @@ -1524,7 +1524,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const removal = agent.session.events.find(event => event.type === 'user/message' + const removal = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.changes.some(change => change.action === 'remove')) expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({ @@ -1558,7 +1558,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message' + const workspaceEvents = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(workspaceEvents).toHaveLength(2) expect(workspaceEvents.some(event => event.type === 'user/message' @@ -1567,7 +1567,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await composeBaselinePrefix(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'user/message' + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions')).toHaveLength(2) } finally { await rm(root, { recursive: true, force: true }) @@ -1790,7 +1790,7 @@ describe('workspace context request injection', () => { // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -1798,7 +1798,7 @@ describe('workspace context request injection', () => { const baselines = baselineEvents(resumed) expect(baselines).toHaveLength(1) - const latest = resumed.session.events.findLast(event => + const latest = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions') expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({ changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }], @@ -1979,7 +1979,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) - const contexts = agent.session.events.filter(event => + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', ) expect(contexts).toHaveLength(1) @@ -2558,14 +2558,14 @@ describe('dynamic nested workspace context injection', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'read and abort' }], source: { kind: 'user' } })) await agent.whenIdle() - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user', )).toHaveLength(0) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'retry the read' }], source: { kind: 'user' } })) await agent.whenIdle() - const contexts = agent.session.events.filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const contexts = agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(contexts).toHaveLength(1) expect(adapter.requests).toHaveLength(3) expect(adapter.requests.at(-1)?.messages.map(blocks => blocksText(blocks.content)).join('\n')) @@ -3540,7 +3540,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, [...agent.session.events]) + const resumed = stubAgent(root, [...agent.session.snapshotEvents()]) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3574,11 +3574,11 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await composeBaselinePrefix(ctx, resumed) - const update = resumed.session.events.findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') + const update = resumed.session.snapshotEvents().findLast(event => event.type === 'user/message' && event.data.source.kind !== 'user') expect(update?.type === 'user/message' && update.data.source).toMatchObject({ changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }], }) @@ -4608,7 +4608,7 @@ describe('workspace context inbox synchronization', () => { callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, [...original.session.events]) + const resumed = stubAgent(root, [...original.session.snapshotEvents()]) await ctx.tools.execute({ signal: testToolSignal, diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index c8914d3695..d2b2a205ba 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -805,7 +805,7 @@ describe('session reference discovery and preparation', () => { expect(JSON.stringify(before)).toContain('durable referenced fact') expect(JSON.stringify(before)).toContain('use @source') expect(JSON.stringify(before)).not.toContain('later source mutation') - expect(Session.create(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before) + expect(Session.create(SessionId('replayed-target'), target.snapshotEvents()).deriveMessages()).toEqual(before) }) it('rejects direct invalid configuration before service publication', async () => { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 8a1e7880c6..6649655709 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -77,12 +77,13 @@ function formatDuration(elapsedMs: number): string { /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { - const start = agent.session.events.findLastIndex( + const events = agent.session.snapshotEvents() + const start = events.findLastIndex( event => event.type === 'turn/start' && event.data.turn === turn, ) const entered = start < 0 ? [] - : agent.session.events.slice(start + 1) + : events.slice(start + 1) .flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...entered, ...proposed] } diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 187e7d85ae..90ec5f2ff2 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -161,11 +161,12 @@ function validateReading( /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) continue - validateReading(session.events.slice(0, index), event, fail) + validateReading(events.slice(0, index), event, fail) } } @@ -179,7 +180,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(session.events, event, fail) + validateReading(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 7f3aa63e17..f461998377 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -67,7 +67,7 @@ function openMessageTurn(session: Session, turn: number, clientTimeZone?: string function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context') { @@ -168,7 +168,7 @@ describe('durable step context', () => { + 'Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.\n' + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) expect(event?.type).toBe('user/message') if (event?.type !== 'user/message') throw new Error('missing time context') // The reading is a `snapshot`-form context: one named contribution whose @@ -291,8 +291,8 @@ describe('durable step context', () => { const original = Session.create(SessionId('seed-source')) openMessageTurn(original, 1) await fire(ctx, sessionAgent(original), 1, 1) - const user = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'user') - const reading = original.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const user = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'user') + const reading = original.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') if (user === undefined || reading === undefined) throw new Error('missing source surface events') original.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'compacted history' }], @@ -304,15 +304,15 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = Session.create(SessionId('resumed'), [...original.events]) + const resumed = Session.create(SessionId('resumed'), [...original.snapshotEvents()]) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) - const beforeSkip = resumed.events.length + const beforeSkip = resumed.snapshotEvents().length await fire(ctx, resumedAgent, 2, 1) - expect(resumed.events).toHaveLength(beforeSkip) + expect(resumed.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(resumed)).toHaveLength(1) vi.setSystemTime(BASE + 1_000) @@ -334,14 +334,14 @@ describe('durable step context', () => { vi.setSystemTime(BASE + 500) openMessageTurn(first, 2) - const beforeSkip = first.events.length + const beforeSkip = first.snapshotEvents().length await fire(ctx, firstAgent, 2, 1) const independent = Session.create(SessionId('interval-independent')) openMessageTurn(independent, 1) await fire(ctx, sessionAgent(independent, 'independent-agent'), 1, 1) - expect(first.events).toHaveLength(beforeSkip) + expect(first.snapshotEvents()).toHaveLength(beforeSkip) expect(contextTexts(first)).toHaveLength(1) expect(contextTexts(independent)).toHaveLength(1) }) @@ -460,7 +460,7 @@ describe('real agent-loop request history', () => { expect(contextTexts(agent.session)).toHaveLength(0) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'step/start')).toBe(false) await ctx.fiber.dispose() }) @@ -482,9 +482,9 @@ describe('real agent-loop request history', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(2) - const contexts = agent.session.events.filter( + const contexts = agent.session.snapshotEvents().filter( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin') - const starts = agent.session.events.filter(event => event.type === 'step/start') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'step/start') expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { @@ -504,7 +504,7 @@ describe('real agent-loop request history', () => { expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') for (const request of adapter.requests) expect(request.system).not.toContain('Time sampled while preparing') - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(JSON.stringify(headers)).not.toContain('Time sampled while preparing') await ctx.fiber.dispose() }) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index af6de0f6b6..cc1c4985d3 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -121,7 +121,7 @@ function openMessageTurn(session: Session, turn: number): void { function contextTexts(session: Session): string[] { const texts: string[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tmux-context') { @@ -169,7 +169,7 @@ describe('tmux-context injection', () => { + 'window active=1, pane active=0, ' + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', ]) - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event?.type !== 'user/message') throw new Error('missing tmux context') // `snapshot` form: one named contribution carrying exactly the reading the // model saw, so a consumer attributes it without re-splitting prose. diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index 80fdfba8f9..b5f65362f7 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -28,7 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail('a loop-built request must carry a frozen messages array') } - const events = session.events + const events = session.snapshotEvents() if (!events.some(event => event.type === 'step/start')) { return fail('a loop-built request with no step/start in its session log') } diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 63b353ebae..e83f0dfc9d 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -33,8 +33,8 @@ export class RuntimeContextProjection { */ constructor(ctx: Context, session: Session) { const surface = new Set(session.surface.nodes) - for (let index = session.events.length - 1; index >= 0; index -= 1) { - const event = session.events[index] + for (let index = session.seq - 1; index >= 0; index -= 1) { + const event = session.eventAt(index) if (event?.type !== 'user/message' || !isOwned(event.data)) continue this.retained ??= null if (surface.has(event.seq)) { diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 395a0333c2..bdee0222c0 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -334,7 +334,7 @@ describe('AgentLoop initiator scope', () => { }]) const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request') expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i) - const call = handle.agent.session.events.find(event => event.type === 'tool/call') + const call = handle.agent.session.snapshotEvents().find(event => event.type === 'tool/call') expect(call?.type === 'tool/call' ? call.data.arguments : undefined) .toBe(JSON.stringify({ path: '/v1/capability' })) expect(captured).toBe(handle.agent) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index fc39e46a28..9507844521 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -35,7 +35,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })) - expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced']) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual(['agent/inbox/spliced']) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) await agent.whenIdle() @@ -47,7 +47,7 @@ describe('Agent', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })) - const injected = agent.session.events.at(-1) + const injected = agent.session.snapshotEvents().at(-1) expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source) .toEqual({ kind: 'plugin', plugin: '' }) }) @@ -97,7 +97,7 @@ describe('Agent', () => { expect(() => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })) }).toThrow(/non-JSON-serializable/) - expect(agent.session.events).toHaveLength(0) + expect(agent.session.snapshotEvents()).toHaveLength(0) }) it('steer() while idle becomes a woken prompt turn', async () => { @@ -108,7 +108,7 @@ describe('Agent', () => { agent.steer(createUserMessage({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() - expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message')).toBe(true) expect(adapter.requests).toHaveLength(1) }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 4abaf954b8..4c148d663d 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -71,7 +71,7 @@ describe('Agent.cancel()', () => { // The prompt ran: its user message is in the log and one turn completed. expect(userTexts(agent)).toEqual(['real prompt']) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) }) it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => { @@ -86,13 +86,13 @@ describe('Agent.cancel()', () => { // A waking send starts and claims synchronously, so keepInbox has no // pending item to preserve by the time this cancellation runs. agent.cancel({ kind: 'user' }, { keepInbox: true }) - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false) await agent.whenIdle() expect(agent.inbox.nextTurn).toHaveLength(0) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) const idle = waitForIdle(ctx, agent) @@ -146,7 +146,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['active', 'B']) expect(adapter.requests).toHaveLength(2) expect(agent.inbox.nextTurn).toHaveLength(0) - expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/end').map(e => e.type === 'turn/end' ? e.data.reason : null)).toEqual([ { kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }, @@ -196,7 +196,7 @@ describe('Agent.cancel()', () => { expect(agent.status).toBe('idle') // No replay with nothing to run: the latched message is gone, so no // empty follow-up turn is recorded. - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(1) }) it('latches a wake arriving deep into a slow abort convergence', async () => { @@ -255,9 +255,9 @@ describe('Agent.cancel()', () => { await new Promise(r => setTimeout(r, 30)) expect(userTexts(agent)).toEqual([]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0) - expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) expect(agent.status).toBe('idle') }) @@ -286,7 +286,7 @@ describe('Agent.cancel()', () => { await driverDone(agent) expect(agent.status).toBe('idle') - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toHaveLength(0) }) @@ -323,7 +323,7 @@ describe('Agent.cancel()', () => { replacementObservation = agent.whenIdle().then(() => ({ status: agent.status, requests: adapter.requests.length, - turns: agent.session.events.filter(event => event.type === 'turn/start').length, + turns: agent.session.snapshotEvents().filter(event => event.type === 'turn/start').length, })) agent.cancel({ kind: 'user' }) replacementRegistered.resolve(undefined) @@ -400,7 +400,7 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) expect(userTexts(agent)).toEqual(['go']) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(adapter.requests).toHaveLength(1) }) @@ -436,8 +436,8 @@ describe('Agent.cancel()', () => { expect(executions).toBe(0) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const call = agent.session.events.find(event => event.type === 'tool/call') - const result = agent.session.events.find(event => event.type === 'tool/result') + const call = agent.session.snapshotEvents().find(event => event.type === 'tool/call') + const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result') expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ message: { @@ -477,7 +477,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toContain('second') // The second turn completed (its reply was streamed). - const reasons = agent.session.events.filter(e => e.type === 'turn/end') + const reasons = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(reasons.length).toBe(2) }) @@ -494,13 +494,13 @@ describe('Agent.cancel()', () => { // The prefix the user watched stream is committed as the step's message, // carrying the truncation marker and citing exactly the chunk events that // delivered it. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'partial' }]) expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) - const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + const chunkSeqs = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk').map(e => e.seq) expect(message?.sourceEventSeqs).toEqual(chunkSeqs) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) @@ -530,7 +530,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) // A usage chunk delivered before the cancel travels with the finalized prefix. @@ -557,10 +557,10 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The undispatched call is dropped whole — no dangling tool_use to pair. - const message = agent.session.events.find(e => e.type === 'assistant/message') + const message = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'reading the file' }]) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) }) it('cancel during error recovery does not finalize the failed stream', async () => { @@ -582,8 +582,8 @@ describe('Agent.cancel()', () => { // The failed stream's prefix stays off the surface: clients reset it on // retry, and provider failures commit nothing. - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) - const end = agent.session.events.find(e => e.type === 'turn/end') + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') }) @@ -603,14 +603,14 @@ describe('Agent.cancel()', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const messages = agent.session.events.filter(e => e.type === 'assistant/message') + const messages = agent.session.snapshotEvents().filter(e => e.type === 'assistant/message') expect(messages).toHaveLength(1) const message = messages[0]! expect(message.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'recovered' }]) expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() // The abandoned attempt's chunks stay out of the completion's source set. - const doomedSeqs = agent.session.events + const doomedSeqs = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/chunk' && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') .map(e => e.seq) @@ -633,7 +633,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'assistant/message')).toBe(false) }) it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { @@ -662,7 +662,7 @@ describe('Agent.cancel()', () => { // log is balanced (the open step was closed by the cancel branch). expect(streamed).toBe(false) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -697,8 +697,8 @@ describe('Agent.cancel()', () => { expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false) - const types = agent.session.events.map(e => e.type) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(false) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length) }) @@ -751,7 +751,7 @@ describe('Agent.cancel()', () => { // No turn opened, no step streamed, and a later prompt still runs (the marker // was reset). expect(streamed).toBe(false) - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(false) }) it('a running-listener cancellation replays replacement work at convergence', async () => { @@ -782,7 +782,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) }) it('a prompt queued during pre-step cancellation replays at convergence', async () => { @@ -805,7 +805,7 @@ describe('Agent.cancel()', () => { await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(3) }) it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { @@ -826,10 +826,10 @@ describe('Agent.cancel()', () => { // started from the dropped steering. await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('idle') - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.length).toBe(1) // only the original (cancelled) turn // The steering text was dropped — it never reached the log. - const flat = agent.session.events + const flat = agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.data.content) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -860,7 +860,7 @@ describe('Agent.cancel()', () => { status: agent.status, requests: adapter.requests.length, users: userTexts(agent), - events: agent.session.events.map(event => event.type), + events: agent.session.snapshotEvents().map(event => event.type), })}`)) }, 1000) }), @@ -871,7 +871,7 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(2) expect(userTexts(agent)).toEqual(['original', 'replacement']) expect(agent.inbox.nextTurn).toHaveLength(0) - const reasons = agent.session.events + const reasons = agent.session.snapshotEvents() .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) @@ -898,7 +898,7 @@ describe('Agent.cancel()', () => { const runtimeReason: unknown = adapter.requests[0]?.signal?.reason expect(runtimeReason).toEqual({ kind: 'parent' }) expect(runtimeReason).toBe(supplied) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' }, @@ -919,7 +919,7 @@ describe('Agent.cancel()', () => { agent.cancel({ kind: 'user' }) await handle.dispose() - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -990,7 +990,7 @@ describe('Agent.cancel()', () => { const idle = agent.whenIdle() agent.cancel({ kind: 'user' }) await idle - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index e01fd73b01..489fd644e2 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -73,7 +73,7 @@ describe('assistant replay provider and model fields', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const recorded = agent.session.events.find(event => event.type === 'assistant/message') + const recorded = agent.session.snapshotEvents().find(event => event.type === 'assistant/message') expect(recorded?.type === 'assistant/message' && recorded.data.message.source).toEqual({ kind: 'model', provider: 'mock', model: 'next-model', replayState, }) @@ -112,7 +112,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') || event.type === 'step/end' || event.type === 'turn/end') @@ -125,7 +125,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'wake') await idle - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) @@ -175,7 +175,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] expect(events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -200,10 +200,10 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start' || event.type === 'step/start' || event.type === 'turn/end').map(event => event.type)) .toEqual(['turn/start', 'turn/end']) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data) .toEqual({ turn: 1, reason: { kind: 'completed' } }) expect(agent.inbox.nextTurn).toHaveLength(0) }) @@ -244,16 +244,16 @@ describe('abort during tool execution ends the turn', () => { await started.promise await fiber.dispose() - expect(agent.session.events + expect(agent.session.snapshotEvents() .flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])) .toEqual([]) expect(agent.inbox.nextStep.map(inboxText)) .toEqual(['accepted result context during disposal']) - expect(agent.session.events.filter(event => event.type === 'turn/start')) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')?.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'disposed' } }) }) @@ -307,7 +307,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'start a text-only turn') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' ? [event.data.content] : [])[0]) @@ -359,7 +359,7 @@ describe('plugin exceptions are contained', () => { send(agent, 'first') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } }, }) @@ -395,8 +395,8 @@ describe('disposal leaves the two-state status contract balanced', () => { expect(statuses).toEqual(['running', 'idle']) expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - const messages = agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .flatMap(event => event.data.content) .flatMap(block => block.type === 'text' ? [block.text] : []) @@ -445,7 +445,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toContain('has no provider/model') @@ -507,7 +507,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { ['content', 'id', 'role', 'source'], ]) expect(targets).toEqual(['next-turn', 'next-step']) - const steeringSources = agent.session.events.flatMap(e => + const steeringSources = agent.session.snapshotEvents().flatMap(e => e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) @@ -534,7 +534,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.snapshotEvents()] }) const forked = new ReactLoopAgent( ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) @@ -603,7 +603,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () expect(errors[0]).toBeInstanceOf(LlmError) expect((errors[0] as LlmError).failure).toEqual(failure) - const events = [...agent.session.events] + const events = [...agent.session.snapshotEvents()] const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. @@ -625,7 +625,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) - expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false) + expect([...agent.session.snapshotEvents()].some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { @@ -647,7 +647,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () }) describe('step boundary publication order', () => { - it('the step/start event is in session.events when its session/event listener fires', async () => { + it('the step/start event is in session.snapshotEvents() when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) @@ -655,7 +655,7 @@ describe('step boundary publication order', () => { const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return - const events = [...subject.events] + const events = [...subject.snapshotEvents()] const last = events.at(-1) observed.push({ turn: event.data.turn, @@ -691,7 +691,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] return { turnStart: e.filter(x => x.type === 'turn/start').length, turnEnd: e.filter(x => x.type === 'turn/end').length, @@ -721,7 +721,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) expect(errors).toEqual([]) @@ -754,7 +754,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'rejected') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'turn/start' + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start' || event.type === 'user/message')).toBe(false) expect(agent.inbox.nextTurn).toHaveLength(1) expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit']) @@ -785,7 +785,7 @@ describe('turn and step boundary recovery', () => { stepEnd: 0, errors: 1, }) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } }, }) }) @@ -878,7 +878,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const turnStarts = e.filter(x => x.type === 'turn/start').length const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) @@ -911,7 +911,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) @@ -940,12 +940,12 @@ describe('turn and step boundary recovery', () => { expect(errors).toEqual([]) // Session contains the observer failure per listener, so the committed turn // remains visible to later observers and executes normally. - const types = [...agent.session.events].map(e => e.type) + const types = [...agent.session.snapshotEvents()].map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) - const lastBoundary = [...agent.session.events].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') + const lastBoundary = [...agent.session.snapshotEvents()].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') - expect(agent.session.events.at(-1)?.type).toBe('turn/end') + expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') // loop survives: a second turn runs normally. send(agent, 'second') @@ -977,7 +977,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') expect(stepEndIdx).toBeGreaterThanOrEqual(0) @@ -1011,7 +1011,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] // Both step/end and turn/end are present — finalization ran to completion. expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) @@ -1041,7 +1041,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - const e1 = [...agent.session.events] + const e1 = [...agent.session.snapshotEvents()] expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1) expect(e1.at(-1)?.type).toBe('turn/end') @@ -1050,7 +1050,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - expect([...agent.session.events].filter(x => x.type === 'turn/end')).toHaveLength(2) + expect([...agent.session.snapshotEvents()].filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1082,7 +1082,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result') + const resultEvent = [...agent.session.snapshotEvents()].find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { expect(resultEvent.data.message.source.callId).toBe(ToolCallId('c1')) @@ -1146,7 +1146,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1194,7 +1194,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1244,7 +1244,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1291,7 +1291,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1336,7 +1336,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - const e = [...agent.session.events] + const e = [...agent.session.snapshotEvents()] expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index d028451481..6c9ecc2020 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -69,13 +69,13 @@ describe('tool JSON parse', () => { await waitForIdle(ctx, agent) // tool/call event should have recorded the raw arguments string - const callEvent = agent.session.events.find(e => e.type === 'tool/call') + const callEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/call') expect(callEvent).toBeDefined() if (callEvent!.type === 'tool/call') { expect(callEvent!.data.arguments).toBe('not json') } // the loop did not crash — a result was produced - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('uses empty object when tool-call arguments are empty string', async () => { @@ -101,7 +101,7 @@ describe('tool JSON parse', () => { send(agent, 'use tool') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) }) @@ -130,9 +130,9 @@ describe('thrown-value propagation', () => { expect(errors).toHaveLength(1) expect(errors[0]).toBe('naked string error') expect(adapter.requests).toHaveLength(0) - const starts = agent.session.events.filter(event => event.type === 'turn/start') - const ends = agent.session.events.filter(event => event.type === 'turn/end') - const messages = agent.session.events.filter(event => event.type === 'user/message') + const starts = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const ends = agent.session.snapshotEvents().filter(event => event.type === 'turn/end') + const messages = agent.session.snapshotEvents().filter(event => event.type === 'user/message') expect(starts).toHaveLength(0) expect(ends).toHaveLength(0) expect(messages).toHaveLength(0) @@ -155,7 +155,7 @@ describe('thrown-value propagation', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message : undefined).toBe('[object Object]') @@ -180,7 +180,7 @@ describe('durable error rendering', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd).toBeDefined() if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') { expect(turnEnd.data.reason.error).toEqual({ @@ -236,7 +236,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note, send(agent, 'go') await waitForIdle(ctx, agent) - const toolResult = agent.session.events.find(e => e.type === 'tool/result') + const toolResult = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(toolResult?.type === 'tool/result' && toolResult.data.message.content[0].isError).toBe(true) expect(toolResult?.type === 'tool/result' && toolResult.data.error) .toEqual({ name: 'HarnessError', code: 'BOOM' }) @@ -262,7 +262,7 @@ describe('request-error action edges', () => { // One failed request, no retry turn. expect(adapter.requests).toHaveLength(1) - const ends = agent.session.events.filter(e => e.type === 'turn/end') + const ends = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(ends).toHaveLength(1) }) @@ -284,7 +284,7 @@ describe('request-error action edges', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('aborted') }) }) @@ -313,7 +313,7 @@ describe('stream failure edges', () => { // No facts -> not offered to recovery; the turn fails through settle(). expect(recoveries).toBe(0) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -388,7 +388,7 @@ describe('tool result meta persistence', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const result = agent.session.events.find(e => e.type === 'tool/result') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.meta).toEqual({ presentation: 'diff-card' }) }) }) @@ -436,7 +436,7 @@ describe('recovery without a retry action', () => { expect(recoveries).toBe(1) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) @@ -461,7 +461,7 @@ describe('unrenderable failure settlement', () => { send(agent, 'go') await agent.whenIdle() - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') if (end?.type === 'turn/end' && end.data.reason.kind === 'error') { // The durable failure keeps the adapter facts' message, not the @@ -502,7 +502,7 @@ describe('driver bookkeeping edges', () => { expect(proposals).toBe(2) expect(adapter.requests).toHaveLength(1) - const end = agent.session.events.findLast(event => event.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' }) }) @@ -524,9 +524,9 @@ describe('driver bookkeeping edges', () => { send(agent, 'go') await agent.whenIdle() - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'step/end')).toHaveLength(1) - const end = agent.session.events.findLast(e => e.type === 'turn/end') + const end = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(end?.type === 'turn/end' && end.data.reason.kind).toBe('error') }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 756d1d7ccc..101e082f99 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -57,7 +57,7 @@ function send(agent: Agent, text: string) { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } describe('agent/pre-step', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 8f0df876c5..0158a2fa15 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -45,7 +45,7 @@ function send(agent: Agent, text: string) { /** All user-message texts recorded in the log (to assert what actually ran). */ function userTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(b => b.type === 'text' ? [b.text] : []) @@ -186,7 +186,7 @@ describe('agent loop', () => { expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { @@ -209,13 +209,13 @@ describe('agent loop', () => { expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end']) - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) // Durable inbox receipt precedes the turn-owned transcript. expect(types[0]).toBe('agent/inbox/spliced') expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/message') - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length }) expect(types.at(-1)).toBe('turn/end') @@ -257,7 +257,7 @@ describe('agent loop', () => { expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }]) // session log records call + result - const types = agent.session.events.map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types).toContain('tool/call') expect(types).toContain('tool/result') }) @@ -319,7 +319,7 @@ describe('agent loop', () => { expect(errors.map(error => error.message)).toEqual([ 'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")', ]) - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' ? turnEnd.data.reason.error.message @@ -336,7 +336,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by DeepSeek Harness.\n\nIn /rescued.') - const turnEnds = agent.session.events.filter(e => e.type === 'turn/end') + const turnEnds = agent.session.snapshotEvents().filter(e => e.type === 'turn/end') expect(turnEnds).toHaveLength(2) expect(turnEnds[1]?.type === 'turn/end' && turnEnds[1].data.reason.kind).toBe('completed') }) @@ -396,7 +396,7 @@ describe('agent loop', () => { let mode = 'read-only' const dispose = ctx.systemPrompt.context({ name: 'policy', order: 0, text: () => `Mode: ${mode}.` }) const agent = ctx.agentLoop.create(SessionId('a-runtime-context'), { provider: 'mock', model: 'mock' }) - const contextEvents = () => agent.session.events.flatMap(event => + const contextEvents = () => agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -437,7 +437,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(contextEvents()).toHaveLength(3) expect(adapter.requests.map(request => request.system)).toEqual(Array(5).fill(adapter.requests[0]?.system)) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -449,7 +449,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -464,7 +464,7 @@ describe('agent loop', () => { send(agent, 'after compaction') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -484,7 +484,7 @@ describe('agent loop', () => { send(agent, 'first') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt') @@ -544,7 +544,7 @@ describe('agent loop', () => { send(agent, 'repair context') await waitForIdle(ctx, agent) - const runtimeContexts = agent.session.events.flatMap(event => + const runtimeContexts = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt' @@ -565,7 +565,7 @@ describe('agent loop', () => { send(agent, 'hi') await waitForIdle(ctx, agent) - const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk') + const chunkEvents = agent.session.snapshotEvents().filter(e => e.type === 'assistant/chunk') // textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7 expect(chunkEvents).toHaveLength(7) // replay: chunk events alone re-assemble to the recorded assistant message @@ -599,13 +599,13 @@ describe('agent loop', () => { send(agent, 'start') await waitForIdle(ctx, agent) - const steering = agent.session.events.find(e => + const steering = agent.session.snapshotEvents().find(e => e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) expect(steering).toBeDefined() // The entered batch is appended after the second step opens and before its // request derives history. const steeringSeq = steering!.seq - const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] + const secondStepStart = agent.session.snapshotEvents().filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) @@ -623,12 +623,12 @@ describe('agent loop', () => { const idle = waitForIdle(ctx, agent) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } })) expect(agent.status).toBe('running') - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } })) await idle - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content)).toEqual([ [{ type: 'text', text: 'first idle steer' }], @@ -656,15 +656,15 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(1) expect(agent.inbox.nextStep).toHaveLength(1) send(agent, 'resume') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering') }) @@ -676,8 +676,8 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [{ type: 'text', text: 'file changed: a.ts' }], source: { kind: 'plugin', plugin: 'watcher' } })) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(0) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'agent/inbox/spliced', data: { target: 'next-step', @@ -691,7 +691,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) const flat = JSON.stringify(adapter.requests[0]!.messages) expect(flat).toContain('file changed: a.ts') expect(flat).not.toContain(' { send(agent, 'go') await waitForIdle(ctx, agent) - const contextEvent = agent.session.events.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') + const contextEvent = agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(contextEvent?.type === 'user/message' && contextEvent.data.source) .toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) const requestText = JSON.stringify(adapter.requests[0]!.messages) @@ -732,7 +732,7 @@ describe('agent loop', () => { agent.inject(createUserMessage({ content: [first], source: { kind: 'plugin', plugin: 'x' } })) first.text = 'mutated after inject' agent.inject(createUserMessage({ content: [{ type: 'text', text: 'second notice' }], source: { kind: 'plugin', plugin: 'x' } })) - visibleDuringTool = agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + visibleDuringTool = agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin') return [{ type: 'text', text: 'ok' }] }, })) @@ -744,10 +744,10 @@ describe('agent loop', () => { // The injection stays in the open turn, but its user-role context cannot // split the assistant tool call from the provider's tool-result message. - const turnStarts = agent.session.events.filter(e => e.type === 'turn/start') + const turnStarts = agent.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts).toHaveLength(1) - const result = agent.session.events.find(e => e.type === 'tool/result')! - const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') + const result = agent.session.snapshotEvents().find(e => e.type === 'tool/result')! + const contexts = agent.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin') expect(contexts).toHaveLength(2) expect(result.seq).toBeLessThan(contexts[0]!.seq) expect(contexts.flatMap(event => event.type === 'user/message' ? event.data.content : [])) @@ -791,7 +791,7 @@ describe('agent loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false) }) it('agent/turn-stopping can steer another step (/loop pattern)', async () => { @@ -836,7 +836,7 @@ describe('agent loop', () => { // only one model call despite the tool call requesting a follow-up expect(adapter.requests).toHaveLength(1) // The tool still executes and durably records its result. - expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/result')).toBe(true) }) it('continues for steering that arrived during a concluding tool step', async () => { @@ -862,7 +862,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const events = agent.session.events.map(event => event.type) + const events = agent.session.snapshotEvents().map(event => event.type) expect(events.filter(type => type === 'turn/end')).toHaveLength(1) expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering') const texts = adapter.requests[1]!.messages @@ -891,7 +891,7 @@ describe('agent loop', () => { expect(adapter.requests[0]!.model).toBe('other-model') // The header event records what the request ACTUALLY used — the switch is // a reconstructable fact, not silent drift. - const headerEvent = agent.session.events.find(e => e.type === 'request/header') + const headerEvent = agent.session.snapshotEvents().find(e => e.type === 'request/header') expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model') }) @@ -930,7 +930,7 @@ describe('agent loop', () => { let boundaryOpen = true ctx.on('agent/pre-step', ({ agent: subject }, next) => { - if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' + if (subject === agent) boundaryOpen = subject.session.snapshotEvents().at(-1)?.type === 'step/start' return next() }) @@ -962,14 +962,14 @@ describe('agent loop', () => { // The first proposal failed inside a balanced turn without calling the model. expect(errors.map(error => error.message)).toEqual(['boom in pre-step']) expect(adapter.requests.length).toBe(0) - expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(event => event.type === 'turn/end')).toBe(true) // The loop survived: a second prompt runs a normal completed turn. send(agent, 'second') await waitForIdle(ctx, agent) expect(adapter.requests.length).toBe(1) - const lastTurnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const lastTurnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(lastTurnEnd?.type === 'turn/end' && lastTurnEnd.data.reason).toEqual({ kind: 'completed' }) }) @@ -1007,7 +1007,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Assert the durable row, not only the live listener. - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -1111,7 +1111,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(executions).toBe(0) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, role: 'user', @@ -1121,7 +1121,7 @@ describe('agent loop', () => { expect(reasons).toEqual([{ kind: 'max-tokens' }]) // Empty content still needs an assistant/message to carry usage; derivation // skips that host so it does not create a spurious assistant turn. - const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') + const assistantMessage = agent.session.snapshotEvents().find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({ turn: 1, step: 1, @@ -1161,7 +1161,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1195,7 +1195,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'completed' }]) - const assistant = agent.session.events.find(e => e.type === 'assistant/message')! + const assistant = agent.session.snapshotEvents().find(e => e.type === 'assistant/message')! expect(assistant.type === 'assistant/message' && assistant.data).toEqual({ turn: 1, step: 1, @@ -1237,7 +1237,7 @@ describe('agent loop', () => { send(agent, 'continue') await waitForIdle(ctx, agent) - expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'tool/call')).toBe(false) // The follow-up request replays the truncated message with its replay // metadata pruned in step with the dropped tool call. expect(adapter.requests[1]?.messages[1]?.source).toEqual({ @@ -1305,7 +1305,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') }) @@ -1326,8 +1326,8 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const messages = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) expect(turns).toHaveLength(1) @@ -1345,8 +1345,8 @@ describe('agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } })) await idle - const turns = agent.session.events.filter(event => event.type === 'turn/start') - const sources = agent.session.events + const turns = agent.session.snapshotEvents().filter(event => event.type === 'turn/start') + const sources = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.source) expect(turns).toHaveLength(2) @@ -1402,10 +1402,10 @@ describe('agent loop', () => { send(agent, 'outer message') await idle - const messages = agent.session.events + const messages = agent.session.snapshotEvents() .filter(event => event.type === 'user/message') .map(event => event.data.content) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2) expect(messages).toEqual([ [{ type: 'text', text: 'outer message' }], [{ type: 'text', text: 'model callback message' }], @@ -1435,7 +1435,7 @@ describe('agent loop', () => { }) expect(reasons[0]).toMatchObject({ kind: 'error' }) // The durable failure and live relay describe the same failed turn. - const turnEnd = agent.session.events.find(e => e.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().find(e => e.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' }) }) @@ -1488,7 +1488,7 @@ describe('agent loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) expect(adapter.requests[0]?.reasoningEffort).toBe(effort) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.reasoningEffort).toBe(effort) }) @@ -1526,11 +1526,11 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.snapshotEvents()] }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types over the inherited prefix - expect(replayed.events.slice(0, agent.session.seq).map(e => e.type)).toEqual( - agent.session.events.map(e => e.type)) - expect(replayed.events.at(-1)?.type).toBe('session/end-seed') + expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( + agent.session.snapshotEvents().map(e => e.type)) + expect(replayed.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 38d135abb3..1dc37ffc14 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -72,26 +72,26 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () } function userMessageTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } function turnNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/start') .map(e => e.data.turn) } function turnEndNumbers(agent: Agent): number[] { - return agent.session.events + return agent.session.snapshotEvents() .filter(e => e.type === 'turn/end') .map(e => (e.data as { turn: number }).turn) } function userMessageCountsByTurn(agent: Agent): number[] { const counts: number[] = [] - for (const event of agent.session.events) { + for (const event of agent.session.snapshotEvents()) { if (event.type === 'turn/start') counts.push(0) if (event.type === 'user/message') counts[counts.length - 1]! += 1 } diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 71633906b3..dd1548cd7c 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const usages = [...agent.session.events] + const usages = [...agent.session.snapshotEvents()] .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 2ee77022a5..17af91a06e 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -92,13 +92,13 @@ describe('agent/request-error', () => { code: 'SERVICE_UNAVAILABLE', }, ]) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) expect(seen.map(item => item.retryPolicy)).toEqual([ expect.objectContaining({ mode: 'normal' }), expect.objectContaining({ mode: 'normal' }), ]) expect(statuses).toEqual(['running', 'idle']) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -115,8 +115,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } }, }) @@ -137,8 +137,8 @@ describe('agent/request-error', () => { await agent.whenIdle() expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) - expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().find(event => event.type === 'turn/end')).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } }, }) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 537ff03df9..3b426c0ca8 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -94,7 +94,7 @@ describe('request stability across the loop', () => { expect(Object.isFrozen(request.messages)).toBe(true) } // One anchoring header snapshot; no further header events (nothing changed). - const headerEvents = agent.session.events.filter(e => e.type === 'request/header') + const headerEvents = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(headerEvents).toHaveLength(1) expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial') }) @@ -111,7 +111,7 @@ describe('request stability across the loop', () => { expect(adapter.requests).toHaveLength(2) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) }) @@ -132,7 +132,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -157,7 +157,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -194,7 +194,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -223,7 +223,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ReasoningEffortId('max'), ]) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ ReasoningEffortId('high'), ReasoningEffortId('max'), @@ -242,7 +242,7 @@ describe('request stability across the loop', () => { const resumedCtx = await harness(resumedAdapter) const resumedHandle = await resumedCtx.agents.create({ sessionId: SessionId(`effort-${model}`), - seed: structuredClone(agent.session.events), + seed: structuredClone(agent.session.snapshotEvents()), agentOptions: { provider: 'mock', model }, }) send(resumedHandle.agent, 'resumed') @@ -250,7 +250,7 @@ describe('request stability across the loop', () => { expect(resumedAdapter.requests[0]?.model).toBe(model) expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort) - const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header') + const resumedHeaders = resumedHandle.agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(effort) expect(resumedHeaders.at(-1)?.data.reason).toBe('resume') } @@ -268,7 +268,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests[0]?.maxTokens).toBe(256_000) - const header = agent.session.events.find(event => event.type === 'request/header') + const header = agent.session.snapshotEvents().find(event => event.type === 'request/header') expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000) expect(header?.type === 'request/header' && header.data.header.adapterDefaults) .toEqual({ maxTokens: true }) @@ -299,7 +299,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(256_000) expect(other.requests[0]?.maxTokens).toBe(8_192) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([ { maxTokens: true }, @@ -333,7 +333,7 @@ describe('request stability across the loop', () => { expect(deepseek.requests[0]?.maxTokens).toBe(4_096) expect(other.requests[0]?.maxTokens).toBe(4_096) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096]) expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined]) }) @@ -385,7 +385,7 @@ describe('request stability across the loop', () => { ReasoningEffortId('high'), ]) expect(second.requests).toHaveLength(0) - const headers = agent.session.events.filter(event => event.type === 'request/header') + const headers = agent.session.snapshotEvents().filter(event => event.type === 'request/header') expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high')) }) @@ -423,7 +423,7 @@ describe('request stability across the loop', () => { expect(signal.aborted).toBe(true) expect(handle.agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) - expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false) + expect(handle.agent.session.snapshotEvents().some(event => event.type === 'request/header')).toBe(false) }) it.each(['plain error', 'LLM error'] as const)( @@ -446,7 +446,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({ + expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')).toMatchObject({ data: { reason: failure instanceof LlmError ? { kind: 'error', error: failure.failure } @@ -519,7 +519,7 @@ describe('request stability across the loop', () => { const second = adapter.requests[1]! // The rewritten history: summary replaces turn 1's user+assistant pair. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true) - expect(agent.session.events.flatMap(event => event.type === 'request/header' + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [{ reason: event.data.reason, startsSeries: event.data.startsSeries }] : [])).toEqual([ { reason: 'initial', startsSeries: undefined }, @@ -557,7 +557,7 @@ describe('request stability across the loop', () => { expect(adapter.requests[1]?.messages[0]?.content).toContainEqual({ type: 'text', text: '[summary for retry]', }) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -570,14 +570,14 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' }) send(agent, 'third') await waitForIdle(ctx, agent) - const snapshots = agent.session.events.filter(e => e.type === 'request/header') + const snapshots = agent.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('change') expect(adapter.requests[2]!.system).toContain('new guidance') @@ -604,7 +604,7 @@ describe('request stability across the loop', () => { const first = adapter.requests[0]! // The inject landed in the log after the boundary: not in THIS request… expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false) - expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true) send(agent, 'second') await waitForIdle(ctx, agent) @@ -631,7 +631,7 @@ describe('request stability across the loop', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } }) if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error() expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i) @@ -650,14 +650,14 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - seed: [...agent.session.events], + seed: [...agent.session.snapshotEvents()], agentOptions: { provider: 'mock', model: 'mock' }, }) const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) - const snapshots = agent2.session.events.filter(e => e.type === 'request/header') + const snapshots = agent2.session.snapshotEvents().filter(e => e.type === 'request/header') expect(snapshots).toHaveLength(2) expect(snapshots[1]?.data.reason).toBe('resume') // Identical header across the restart: byte-identical continuation. @@ -689,7 +689,7 @@ describe('request stability across the loop', () => { // The second turn reuses the same series and header; the session's own // fold remains immutable state. - expect(agent.session.events.flatMap(event => + expect(agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) expect(Object.isFrozen(agent.session.requestHeader())).toBe(true) expect(adapter.requests[1]!.temperature).toBeUndefined() @@ -715,7 +715,7 @@ describe('request stability across the loop', () => { await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(3) - const events = agent.session.events + const events = agent.session.snapshotEvents() const stepStarts = events.filter(e => e.type === 'step/start') expect(stepStarts).toHaveLength(3) @@ -771,7 +771,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - const records = agent.session.events.filter(event => event.type === 'request/context') + const records = agent.session.snapshotEvents().filter(event => event.type === 'request/context') expect(records).toHaveLength(1) expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 }) // Log-only: not a SurfaceEventType, so it can never reach a model request @@ -796,7 +796,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data.contextWindow)).toEqual([64_000, 256_000]) }) @@ -808,7 +808,7 @@ describe('request/context capacity records', () => { await waitForIdle(ctx, agent) send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([{ provider: 'mock', model: 'mock' }]) }) @@ -828,7 +828,7 @@ describe('request/context capacity records', () => { send(agent, 'second') await waitForIdle(ctx, agent) - expect(agent.session.events + expect(agent.session.snapshotEvents() .filter(event => event.type === 'request/context') .map(event => event.data)).toEqual([ { provider: 'mock', model: 'known', contextWindow: 64_000 }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index df2849d39d..4688a97fa8 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -159,7 +159,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', })) await waitForIdle(ctx, handle.agent) expect(handle.agent.session.deriveMessages()).toHaveLength(5) - expect(handle.agent.session.events.at(-1)).toMatchObject({ + expect(handle.agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -317,7 +317,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setup: async (agentCtx) => { expect(agentCtx.agent?.id).toBe(sessionId) // The two persisted events plus the end-seed marker. - expect(agentCtx.agent?.session.events).toHaveLength(3) + expect(agentCtx.agent?.session.snapshotEvents()).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - const events1 = [...a1.session.events] + const events1 = [...a1.session.snapshotEvents()] const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous await ctx1.fiber.dispose() @@ -672,18 +672,18 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') // …followed by one end-seed event marking the constructor seed. - expect(a2.session.events.length).toBe(events1.length + 1) + expect(a2.session.snapshotEvents().length).toBe(events1.length + 1) expect(a2.session.firstLiveSeq).toBe(events1.length) - expect(a2.session.events.at(-1)?.type).toBe('session/end-seed') + expect(a2.session.snapshotEvents().at(-1)?.type).toBe('session/end-seed') const replay = Session.create(SessionId('replay'), events1) expect(a2.session.deriveMessages()).toEqual(replay.deriveMessages()) // …and a new turn continues numbering (turn 2) with contiguous seqs. a2.followup(createUserMessage({ content: [{ type: 'text', text: 'second question' }], source: { kind: 'user' } })) await waitForIdle(ctx2, a2) - const allSeqs = a2.session.events.map(e => e.seq) + const allSeqs = a2.session.snapshotEvents().map(e => e.seq) expect(allSeqs).toEqual(allSeqs.map((_, i) => i)) // 0..N contiguous, no duplicates - const turnStarts = a2.session.events.filter(e => e.type === 'turn/start') + const turnStarts = a2.session.snapshotEvents().filter(e => e.type === 'turn/start') expect(turnStarts.map(e => e.type === 'turn/start' && e.data.turn)).toEqual([1, 2]) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6b78213200..6d9e7b33e9 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -781,7 +781,7 @@ describe('agent scope lifecycle', () => { expect(statuses).toEqual([]) expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) - expect(announced.session.events).toEqual([]) + expect(announced.session.snapshotEvents()).toEqual([]) expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -1086,9 +1086,9 @@ describe('agent scope lifecycle', () => { // are empty and nothing still drives the detached session. expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.id)).toBeUndefined() - const eventsAfter = agent.session.events.length + const eventsAfter = agent.session.snapshotEvents().length await new Promise(resolve => setTimeout(resolve, 30)) - expect(agent.session.events.length).toBe(eventsAfter) + expect(agent.session.snapshotEvents().length).toBe(eventsAfter) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index e4d2c993e9..8adccaa4b5 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -42,7 +42,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** Build one assistant response containing the supplied tool calls. */ diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 563e5ba6c3..e7ffe77a5c 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -69,7 +69,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf describe('loop-level canonical tool order', () => { it('logs the request/header with tools in canonical order, not registration order', async () => { const { agent, adapter } = await runTurn(['zulu', 'alpha', 'mike']) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['alpha', 'mike', 'zulu']) // The dispatched request is built FROM the logged header (whose tools the // assembly already canonicalized) and reaches the adapter deep-frozen — @@ -82,14 +82,14 @@ describe('loop-level canonical tool order', () => { it('produces the same header order for any registration order', async () => { const first = await runTurn(['alpha', 'mike', 'zulu']) const second = await runTurn(['zulu', 'mike', 'alpha']) - const names = (run: typeof first) => foldRequestHeader(run.agent.session.events)?.tools?.map(tool => tool.name) + const names = (run: typeof first) => foldRequestHeader(run.agent.session.snapshotEvents())?.tools?.map(tool => tool.name) expect(names(first)).toEqual(['alpha', 'mike', 'zulu']) expect(names(second)).toEqual(names(first)) }) it('honors a configured toolOrder in the logged header and the dispatched request', async () => { const { agent, adapter } = await runTurn(['alpha', 'zulu', 'mike'], ['zulu', TOOL_ORDER_REST]) - const header = foldRequestHeader(agent.session.events) + const header = foldRequestHeader(agent.session.snapshotEvents()) expect(header?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'mike']) expect(Object.isFrozen(adapter.requests[0])).toBe(true) @@ -103,10 +103,10 @@ describe('loop-level canonical tool order', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) - expect(foldRequestHeader(agent.session.events)).toBeUndefined() - expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true) - expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true) - expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false) - expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false) + expect(foldRequestHeader(agent.session.snapshotEvents())).toBeUndefined() + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/start')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'turn/end')).toBe(true) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/start')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'step/end')).toBe(false) }) }) diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index c6b9204c92..fccfdcba64 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -29,7 +29,7 @@ export class Inbox { private readonly session: Session, private readonly notifications: InboxNotifications, ) { - for (const event of session.events.slice(session.header.seedLength ?? 0)) { + for (const event of session.snapshotEvents(session.header.seedLength ?? 0)) { if (event.type !== 'agent/inbox/spliced') continue try { this.apply(event.data) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 33ecfb3c7b..e1da38b652 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -124,13 +124,13 @@ describe('Inbox', () => { const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) inbox.append('next-turn', nextTurn) inbox.append('next-step', nextStep) - const beforeClear = session.events.length + const beforeClear = session.snapshotEvents().length inbox.clear() expect(inbox.hasPending).toBe(false) expect(discarded).toEqual([nextStep, nextTurn]) - expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' + expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' ? event.data : event.type)).toEqual([ { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, @@ -138,7 +138,7 @@ describe('Inbox', () => { ]) inbox.clear() - expect(session.events).toHaveLength(beforeClear + 2) + expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) }) }) diff --git a/packages/core/agent/tests/consumed-work.spec.ts b/packages/core/agent/tests/consumed-work.spec.ts index 3b79d67620..26808c5c81 100644 --- a/packages/core/agent/tests/consumed-work.spec.ts +++ b/packages/core/agent/tests/consumed-work.spec.ts @@ -40,7 +40,7 @@ describe('foldConsumedWork', () => { const session = Session.create(SessionId('empty')) accept(session, 'queued') - expect(foldConsumedWork(session.events)).toEqual({ droppedUnrun: false }) + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ droppedUnrun: false }) }) it('reports the latest turn that entered a model step', () => { @@ -48,7 +48,7 @@ describe('foldConsumedWork', () => { steppedTurn(session, 1, { kind: 'completed' }) steppedTurn(session, 2, { kind: 'max-tokens' }) - expect(foldConsumedWork(session.events).end?.data) + expect(foldConsumedWork(session.snapshotEvents()).end?.data) .toEqual({ turn: 2, reason: { kind: 'max-tokens' } }) }) @@ -61,7 +61,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'error', error: { message: 'ENOSPC', code: 'UNKNOWN' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('reports a turn that claimed its input and was then stopped before any step', () => { @@ -71,7 +71,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a turn stopped, failed, or rejected without taking any input', () => { @@ -85,7 +85,7 @@ describe('foldConsumedWork', () => { session.append('turn/end', { turn: 4, reason: { kind: 'blocked' } }) // None of these turns describes work: they opened, found nothing of their own, and closed. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports a turn whose claimed input a pre-step rejection discarded', () => { @@ -97,7 +97,7 @@ describe('foldConsumedWork', () => { // Rejection does not retain the claimed messages, so the `blocked` end is // the only account of input that will never run. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(2) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(2) }) it('ignores a claim its own turn emptied', () => { @@ -109,7 +109,7 @@ describe('foldConsumedWork', () => { // An emptied claim ran nothing and dropped nothing: a listener rewrote the // batch away, which is not this log's account of the work. - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('credits a claim with no open turn to no turn at all', () => { @@ -120,7 +120,7 @@ describe('foldConsumedWork', () => { claim(session) session.append('turn/end', { turn: 2, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - expect(foldConsumedWork(session.events).end?.data.turn).toBe(1) + expect(foldConsumedWork(session.snapshotEvents()).end?.data.turn).toBe(1) }) it('reports work cancelled out of the inbox after the last accounting turn', () => { @@ -130,8 +130,8 @@ describe('foldConsumedWork', () => { cancelPending(session) // No turn opened over it, so only the cancellation says the work was cut short. - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.find(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().find(event => event.type === 'turn/end'), droppedUnrun: true, }) }) @@ -143,7 +143,7 @@ describe('foldConsumedWork', () => { target: 'next-turn', start: 0, removedCount: 1, inserted: [message('rewritten')], outcome: 'canceled', }) - expect(foldConsumedWork(session.events).droppedUnrun).toBe(false) + expect(foldConsumedWork(session.snapshotEvents()).droppedUnrun).toBe(false) }) it('lets a later accounting turn absorb an earlier drop', () => { @@ -152,8 +152,8 @@ describe('foldConsumedWork', () => { cancelPending(session) steppedTurn(session, 2, { kind: 'completed' }) - expect(foldConsumedWork(session.events)).toEqual({ - end: session.events.findLast(event => event.type === 'turn/end'), + expect(foldConsumedWork(session.snapshotEvents())).toEqual({ + end: session.snapshotEvents().findLast(event => event.type === 'turn/end'), droppedUnrun: false, }) }) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index a1255e2fa4..20d1182786 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/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/core/session/README.md -README.md: b96423846de1f0c902ed656ff879f7d9eb0536ef -README.zh.md: 61f1ff77f9427e121b56ae0205b36e7512782030 +README.md: f490c53c846c00f8d0c91b7ad7fae3f688522a31 +README.zh.md: 0f32bc5ba04e6da2bc8907280ef2c8ea9dc81b9a diff --git a/packages/core/session/README.md b/packages/core/session/README.md index b96423846d..f490c53c84 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,6 +49,12 @@ session.deriveMessages() // the derived model history Surface events (`user/message`, `assistant/message`, `tool/result`) must declare how they join the ordered surface; raw chunks, boundaries, and other log-only events never produce a message. +### Read the log + +`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. + +`session.seeded` distinguishes construction with a replay, fork, or restored seed—including an explicitly empty seed—from a fresh session without scanning for `session/end-seed`. + ### Fork a session `ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 61f1ff77f9..0f32bc5ba0 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -49,6 +49,12 @@ session.deriveMessages() // the derived model history 表层事件(`user/message`、`assistant/message`、`tool/result`)必须声明如何进入有序 surface;原始分片、边界与其他仅日志事件从不产生消息。 +### 读取日志 + +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 + +`session.seeded` 可区分以 replay、fork 或恢复 seed(包括显式空 seed)构造的会话与全新会话,无需扫描 `session/end-seed`。 + ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 diff --git a/packages/core/session/src/chunk-rows.ts b/packages/core/session/src/chunk-rows.ts index b50a763a12..88d6d9e82b 100644 --- a/packages/core/session/src/chunk-rows.ts +++ b/packages/core/session/src/chunk-rows.ts @@ -7,7 +7,7 @@ * `tool-call-chunks` — and expands rows back to the exact original events. * * Packed rows are an encoding vocabulary, NOT session events: they never enter - * `Session.events`, have no `SessionEventMap` entry, and use bare (slash-less) + * `Session.snapshotEvents()`, have no `SessionEventMap` entry, and use bare (slash-less) * type tags so a reader cannot confuse them with the event taxonomy * (precedent: the JSONL header line's `session` tag). Persistence and bounded * history transport both use the codec. The encoder whitelists exact shapes — diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 84a89d325f..3d934b0884 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -469,6 +469,14 @@ export class Session { */ readonly firstLiveSeq: number + /** + * Whether construction received a replay, fork, or restored seed. + * Every seeded lifecycle has a `session/end-seed` marker after construction; + * this field distinguishes an explicitly empty seed from a fresh session + * without scanning the log. + */ + readonly seeded: boolean + /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -535,6 +543,7 @@ export class Session { } } this.firstLiveSeq = this.log.length + this.seeded = seed !== undefined this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped @@ -545,18 +554,32 @@ export class Session { } } - /** Cached immutable public snapshot of the private append-only log. */ + /** Cached immutable full snapshot of the private append-only log. */ private eventsSnapshot: readonly SessionEvent[] | undefined /** - * An immutable snapshot of the append-only event log. The snapshot is reused - * until the next append; a previously returned array does not grow later. - * Events and their nested data are deep-frozen at acceptance, so neither a - * cast nor ordinary JavaScript can rewrite durable history. + * Return the immutable event stored at one exact sequence number. + * @param seq - event sequence number. + * @returns the accepted event, or undefined when the log does not contain it. */ - get events(): readonly SessionEvent[] { - this.eventsSnapshot ??= Object.freeze([...this.log]) - return this.eventsSnapshot + eventAt(seq: number): SessionEvent | undefined { + return this.log[seq] + } + + /** + * Materialize an immutable snapshot of a half-open event sequence range. + * A full current snapshot is reused until the next append; every previously + * returned snapshot remains stable after later appends. + * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. + * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. + * @returns a frozen array of the selected deeply frozen events. + */ + snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[] { + if (fromSeq === 0 && toSeqExclusive === this.log.length) { + this.eventsSnapshot ??= Object.freeze([...this.log]) + return this.eventsSnapshot + } + return Object.freeze(this.log.slice(fromSeq, toSeqExclusive)) } /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ @@ -661,7 +684,7 @@ export class Session { * The {@link EpochHeader} in force after the log's last header event — the * header the NEXT request will be compared against — or undefined before * the first `request/header` snapshot. The live, incrementally-maintained - * form of `foldRequestHeader(session.events)`: each header event is folded + * form of `foldRequestHeader(session.snapshotEvents())`: each header event is folded * once, when first seen, so a per-step read costs O(new events). * @returns the folded header, or undefined when no header event exists yet. */ @@ -1092,9 +1115,8 @@ export class SessionStore extends Service { }) } - private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] { - const events = session.events - const lastEvent = events.at(-1) + private _forkSeed(session: Session, requestedBoundary: number | undefined): readonly SessionEvent[] { + const lastEvent = session.eventAt(session.seq - 1) let boundary: number if (requestedBoundary !== undefined) { boundary = requestedBoundary @@ -1108,22 +1130,23 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } - if (boundary >= events.length) { - const lastSeq = events.at(-1)?.seq + if (boundary >= session.seq) { + const lastSeq = lastEvent?.seq throw new SessionForkError( `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`, 'INVALID_BOUNDARY', ) } - const boundaryEvent = events[boundary] + const boundaryEvent = session.eventAt(boundary) if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { throw new SessionForkError( `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`, 'INVALID_BOUNDARY', ) } - const lastTurnBoundary = events.slice(0, boundary + 1) + const events = session.snapshotEvents(0, boundary + 1) + const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') if (lastTurnBoundary?.type === 'turn/start') { throw new SessionForkError( @@ -1132,7 +1155,7 @@ export class SessionStore extends Service { ) } - return events.slice(0, boundary + 1) + return events } private _resolveForkSource(source: SessionForkSource): Session { diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 5512cd0b97..88a1646a06 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -206,7 +206,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { applyTransition(trace, validateEvent(trace, event, fail)) } return trace diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index f93c898375..364aa44a61 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,7 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.events]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.snapshotEvents()]).deriveMessages() } describe('derived-message cache', () => { diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 6a7770b4b6..537bf9c3ee 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -47,14 +47,14 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m } function lastSeq(session: Session): number { - const event = session.events.at(-1) + const event = session.snapshotEvents().at(-1) if (event === undefined) throw new Error('missing last event') return event.seq } /** A seeded child's constructor seed: its log minus the end-seed marker. */ function inherited(session: Session): readonly SessionEvent[] { - const events = session.events + const events = session.snapshotEvents() const last = events.at(-1) if (last?.type !== 'session/end-seed') throw new Error('seeded child is missing its end-seed marker') return events.slice(0, -1) @@ -83,19 +83,19 @@ describe('SessionStore.fork', () => { const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) - expect(inherited(child)).toEqual(source.events) - expect(child.events).not.toBe(source.events) - expect(child.events[1]).not.toBe(source.events[1]) + expect(inherited(child)).toEqual(source.snapshotEvents()) + expect(child.snapshotEvents()).not.toBe(source.snapshotEvents()) + expect(child.snapshotEvents()[1]).not.toBe(source.snapshotEvents()[1]) expect(() => { - firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' } + firstUserMessage(child.snapshotEvents()).data.content[0] = { type: 'text', text: 'child mutation' } }).toThrow(TypeError) - expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) - expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(source.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(firstUserMessage(child.snapshotEvents()).data.content).toEqual([{ type: 'text', text: 'hello' }]) expect(child.header).toMatchObject({ id: SessionId('child'), cwd: '/workspace', parentSession: SessionId('parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -107,7 +107,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('log-only-child')) - expect(inherited(child)).toEqual(source.events) + expect(inherited(child)).toEqual(source.snapshotEvents()) expect(inherited(child).at(-1)).toMatchObject({ type: 'test/log-only', data: { value: 'after execution' }, @@ -124,7 +124,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) - expect(inherited(child)).toEqual(source.events.slice(0, firstBoundary + 1)) + expect(inherited(child)).toEqual(source.snapshotEvents().slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) expect(child.deriveMessages()).toEqual([{ id: expect.any(String) as unknown, @@ -152,7 +152,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, lastSeq(source), SessionId(`child-${index}`)) expect(inherited(child).at(-1)?.type).toBe('turn/end') - expect(child.header.seedLength).toBe(source.events.length) + expect(child.header.seedLength).toBe(source.snapshotEvents().length) } }) @@ -167,10 +167,10 @@ describe('SessionStore.fork', () => { const child = sessions.fork(parent, undefined, SessionId('bracket-child')) // Parent: no end-seed event follows the bracket, so its owner treats it as live. - expect(parent.events.at(-1)).toBe(open) - expect(parent.events.some(event => event.type === 'session/end-seed')).toBe(false) + expect(parent.snapshotEvents().at(-1)).toBe(open) + expect(parent.snapshotEvents().some(event => event.type === 'session/end-seed')).toBe(false) // Child: the same bracket is before end-seed, so it belongs to the seed. - const boundary = child.events.at(-1) + const boundary = child.snapshotEvents().at(-1) expect(boundary).toMatchObject({ type: 'session/end-seed' }) expect(boundary!.seq).toBeGreaterThan(open.seq) expect(child.firstLiveSeq).toBe(open.seq + 1) diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 0b9a282d45..26aede8930 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -79,7 +79,7 @@ describe('session-log invariants', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('later dispatch veto') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(() => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -413,7 +413,7 @@ describe('session-log invariants', () => { const open = ctx.sessions.create(SessionId('inherited-inside-open-turn'), { seed: [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, ] }) - expect(open.events.map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) + expect(open.snapshotEvents().map(event => event.type)).toEqual(['turn/start', 'session/end-seed']) // Still open afterwards: the boundary moves no cursor. expect(() => open.append('turn/start', { turn: 2 })) .toThrow(/turn 1 is still open/) diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index e27c0c8d44..4829d58a89 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -102,7 +102,7 @@ describe('Session properties', () => { it('seq is strictly monotonic and zero-based contiguous', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) - session.events.forEach((event, i) => { expect(event.seq).toBe(i) }) + session.snapshotEvents().forEach((event, i) => { expect(event.seq).toBe(i) }) expect(session.seq).toBe(events.length) })) }) @@ -110,10 +110,10 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.events]) + const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.snapshotEvents()]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) })) }) @@ -121,10 +121,10 @@ describe('Session properties', () => { it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.events]) - const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.events]) + const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.snapshotEvents()]) + const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.snapshotEvents()]) // Lazy resume makes browsing a pickup, so this must not grow per open. - expect(twice.events).toEqual(once.events) + expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) }) @@ -157,7 +157,7 @@ describe('Session properties', () => { fc.assert(fc.property(logArb, (events) => { const session = build(events) const messages = session.deriveMessages() - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) for (const m of messages) { expect(['user', 'assistant', 'system']).toContain(m.role) // Derived messages are frozen shared projections: mutation THROWS @@ -165,7 +165,7 @@ describe('Session properties', () => { expect(Object.isFrozen(m)).toBe(true) expect(() => { m.content.push({ type: 'text', text: 'mutation' }) }).toThrow(TypeError) } - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) })) }) }) diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 764b615877..17d7ba80de 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -82,7 +82,7 @@ describe('foldRequestHeader', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' }) - expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } }) + expect(foldRequestHeader(session.snapshotEvents())).toEqual({ config: { provider: 'mock', model: 'other' } }) }) }) @@ -97,7 +97,7 @@ describe('legacy request-header format', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: CONFIG })) .toThrow(/unsupported legacy request\/header-delta/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects the removed fallback reason in seeds and untyped appends', () => { @@ -111,7 +111,7 @@ describe('legacy request-header format', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header', { header: { config: CONFIG }, reason: 'fallback' })) .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d74112c833..05a5398877 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -65,7 +65,7 @@ describe('Session', () => { session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } }) - const turnEnd = session.events.findLast(e => e.type === 'turn/end')! + const turnEnd = session.snapshotEvents().findLast(e => e.type === 'turn/end')! expect(turnEnd.data.reason).toEqual({ kind: 'max-tokens' }) // survives a structuredClone (the persistence-serialization boundary) expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' }) @@ -75,9 +75,9 @@ describe('Session', () => { const session = Session.create(SessionId('aborted')) session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }) - const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.events)) - expect(replayed.events.slice(0, -1)).toEqual(session.events) - const turnEnd = replayed.events.findLast(event => event.type === 'turn/end') + const replayed = Session.create(SessionId('aborted-replay'), structuredClone(session.snapshotEvents())) + expect(replayed.snapshotEvents().slice(0, -1)).toEqual(session.snapshotEvents()) + const turnEnd = replayed.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason) .toEqual({ kind: 'aborted', reason: { kind: 'user' } }) }) @@ -109,7 +109,7 @@ describe('Session', () => { session.append('user/message', message, { surfaceOp: 'append' }) expect(session.deriveMessages()).toEqual([message]) - const event = session.events[0] + const event = session.snapshotEvents()[0] expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'agent-instructions' }) }) @@ -132,27 +132,29 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('s3-replay'), [...original.events]) + const replayed = Session.create(SessionId('s3-replay'), [...original.snapshotEvents()]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.seq).toBe(original.seq + 1) expect(replayed.firstLiveSeq).toBe(original.seq) }) it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) - expect(fresh.events).toEqual([]) + expect(fresh.seeded).toBe(false) + expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) + expect(resumed.seeded).toBe(true) expect(resumed.firstLiveSeq).toBe(0) - expect(resumed.events).toMatchObject([ + expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, ]) - const reopened = Session.create(SessionId('reopened-empty'), resumed.events) + const reopened = Session.create(SessionId('reopened-empty'), resumed.snapshotEvents()) expect(reopened.firstLiveSeq).toBe(1) - expect(reopened.events).toEqual(resumed.events) + expect(reopened.snapshotEvents()).toEqual(resumed.snapshotEvents()) }) it('rejects pre-provider request headers and assistant messages on seed/load', () => { @@ -181,7 +183,7 @@ describe('Session', () => { const unrelatedPrimitiveData = { type: 'plugin/event', seq: 0, time: 1, data: null, } as unknown as SessionEvent - expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).events.slice(0, 1)) + expect(Session.create(SessionId('primitive-plugin-data'), [unrelatedPrimitiveData]).snapshotEvents().slice(0, 1)) .toEqual([unrelatedPrimitiveData]) }) @@ -387,7 +389,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('reasoning-effort'), [valid]).events[0]) + expect(Session.create(SessionId('reasoning-effort'), [valid]).snapshotEvents()[0]) .toEqual(valid) for (const reasoningEffort of ['', 1]) { @@ -417,7 +419,7 @@ describe('Session', () => { reason: 'initial', }, } as const - expect(Session.create(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid) + expect(Session.create(SessionId('adapter-defaults'), [valid]).snapshotEvents()[0]).toEqual(valid) for (const adapterDefaults of [ null, @@ -447,7 +449,7 @@ describe('Session', () => { isError: false, }), }, { surfaceOp: 'append' }) - const before = structuredClone(session.events) + const before = structuredClone(session.snapshotEvents()) // A misbehaving consumer tries to mutate the messages it was handed. const messages = session.deriveMessages() @@ -463,7 +465,7 @@ describe('Session', () => { messages.reverse() // The log is unchanged: deep-equal to the snapshot taken before mutation. - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) // And a fresh derivation still reflects the original content and order. expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }]) }) @@ -492,7 +494,7 @@ describe('Session', () => { cyclic['self'] = cyclic expect(bad(cyclic)).toThrow(/non-JSON-serializable/) // The rejected appends never entered the log. - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { @@ -506,13 +508,13 @@ describe('Session', () => { }))) .toThrow(/surface-eligible and requires a surfaceOp marker/) // The rejected append never entered the log (only turn/start is present). - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) it('accepts dense arrays and nested plain objects', () => { const session = Session.create(SessionId('s6')) expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never, { surfaceOp: 'append' })).not.toThrow() - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) it('validates seed events: rejects a non-JSON-serializable seed', () => { @@ -556,7 +558,7 @@ describe('Session', () => { { type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } }, ] as SessionEvent[] const session = Session.create(SessionId('seed-ok'), goodSeed) - expect(session.events.slice(0, 3)).toEqual(goodSeed) + expect(session.snapshotEvents().slice(0, 3)).toEqual(goodSeed) expect(session.firstLiveSeq).toBe(3) }) @@ -581,7 +583,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-entry-snapshot'), seed) expect(reads).toBe(1) - expect(session.events.slice(0, 1)).toEqual([accepted]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([accepted]) }) it('reads a nested seed-data getter once and stores its first JSON value', () => { @@ -598,7 +600,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-nested-drift'), seed) expect(reads).toBe(1) - expect(session.events[0]!.data).toEqual({ value: 'accepted' }) + expect(session.snapshotEvents()[0]!.data).toEqual({ value: 'accepted' }) }) it('rejects non-JSON surface metadata in a seed event', () => { @@ -659,7 +661,7 @@ describe('Session', () => { const session = Session.create(SessionId('seed-null-prototype'), [event]) - expect(session.events.slice(0, 1)).toEqual([{ ...event }]) + expect(session.snapshotEvents().slice(0, 1)).toEqual([{ ...event }]) }) it('reads a nested seed-metadata getter once and stores its first JSON value', () => { @@ -691,7 +693,7 @@ describe('Session', () => { }] as unknown as SessionEvent[] const session = Session.create(SessionId('seed-unstable-metadata'), seed) - const event = session.events[1]! + const event = session.snapshotEvents()[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') expect(reads).toBe(1) @@ -734,7 +736,7 @@ describe('Session', () => { } }) - it('snapshots the seed: mutating the original after construction does not affect session.events', () => { + it('snapshots the seed: mutating the original after construction does not affect session.snapshotEvents()', () => { const seed = [ { type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1 } }, { type: 'user/message' as const, seq: 1, time: 2, data: { @@ -747,16 +749,16 @@ describe('Session', () => { const session = Session.create(SessionId('seed-snapshot'), seed) // Mutate the ORIGINAL seed objects after construction: a shared reference // would let this rewrite the forked log (or reintroduce non-serializable - // data past validation). The snapshot must shield session.events. + // data past validation). The snapshot must shield session.snapshotEvents(). const um = seed[1]! ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' ;(um.data as Record)['injected'] = 1n // would have failed validation - const logged = session.events[1]! + const logged = session.snapshotEvents()[1]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() }) - it('snapshots append data: mutating the passed object after append does not affect session.events', () => { + it('snapshots append data: mutating the passed object after append does not affect session.snapshotEvents()', () => { const session = Session.create(SessionId('append-snapshot')) const data = { id: MessageId('append-input'), @@ -766,10 +768,10 @@ describe('Session', () => { } const event = session.append('user/message', data, { surfaceOp: 'append' }) // Mutate the caller's object after append returns. A shared reference would - // make session.events diverge from the value that passed validation. + // make session.snapshotEvents() diverge from the value that passed validation. data.content[0]!.text = 'HACKED' ;(data as Record)['injected'] = 1n - const logged = session.events[0]! + const logged = session.snapshotEvents()[0]! expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') expect((logged.data as Record)['injected']).toBeUndefined() // The returned event carries the same snapshot, not the caller's input. @@ -791,7 +793,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.data).toEqual({ value: 'accepted' }) - expect(session.events).toEqual([event]) + expect(session.snapshotEvents()).toEqual([event]) }) it('rejects non-JSON surface metadata before appending the event', () => { @@ -804,7 +806,7 @@ describe('Session', () => { }), { surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects exotic surface metadata before cloning can erase its prototype', () => { @@ -822,7 +824,7 @@ describe('Session', () => { }), { surfaceOp: new ReplaceOp() }, )).toThrow(/non-JSON-serializable surface metadata/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('reads a nested append-metadata getter once and stores its first JSON value', () => { @@ -853,7 +855,7 @@ describe('Session', () => { expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(session.events).toEqual([source, event]) + expect(session.snapshotEvents()).toEqual([source, event]) }) it('rejects invalid plain surface metadata shapes at append', () => { @@ -874,7 +876,7 @@ describe('Session', () => { surfaceOp: 'append', sourceEventSeqs: [0, -1], })).toThrow(/non-negative safe integers/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('rejects surface metadata on non-surface append and seed events', () => { @@ -897,7 +899,7 @@ describe('Session', () => { data: { turn: 1 }, surfaceOp: 'append', } as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it('deep-freezes seeded and appended event snapshots', () => { @@ -907,7 +909,7 @@ describe('Session', () => { time: 1, data: { turn: 1 }, }]) - const seededEvent = seeded.events[0]! + const seededEvent = seeded.snapshotEvents()[0]! if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') expect(Object.isFrozen(seededEvent)).toBe(true) expect(Object.isFrozen(seededEvent.data)).toBe(true) @@ -957,21 +959,38 @@ describe('Session', () => { it('returns cached frozen event-array snapshots that do not grow after append', () => { const session = Session.create(SessionId('events-snapshot')) session.append('turn/start', { turn: 1 }) - const before = session.events + const before = session.snapshotEvents() const beforeEvent = before[0]! if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start') - expect(session.events).toBe(before) + expect(session.snapshotEvents()).toBe(before) expect(Object.isFrozen(before)).toBe(true) expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError) expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const after = session.events + const after = session.snapshotEvents() expect(before).toHaveLength(1) expect(after).toHaveLength(2) expect(after).not.toBe(before) - expect(session.events).toBe(after) + expect(session.snapshotEvents()).toBe(after) + }) + + it('reads one event without materializing an array and snapshots half-open ranges', () => { + const session = Session.create(SessionId('event-reads')) + const start = session.append('turn/start', { turn: 1 }) + const end = session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + expect(session.eventAt(0)).toBe(start) + expect(session.eventAt(1)).toBe(end) + expect(session.eventAt(2)).toBeUndefined() + + const range = session.snapshotEvents(1, 2) + expect(range).toEqual([end]) + expect(Object.isFrozen(range)).toBe(true) + + session.append('turn/start', { turn: 2 }) + expect(range).toEqual([end]) }) it('detaches and freezes an explicitly supplied session header', () => { @@ -1122,7 +1141,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.snapshotEvents()] }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) @@ -1386,7 +1405,7 @@ describe('SessionStore', () => { const heard: SessionEvent[] = [] let committedBeforeNotify = false ctx.on('session/event', (observedSession, event) => { - committedBeforeNotify = observedSession.events.at(-1) === event + committedBeforeNotify = observedSession.snapshotEvents().at(-1) === event throw new Error('sync event observer') }) ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never) @@ -1399,7 +1418,7 @@ describe('SessionStore', () => { }) }).not.toThrow() expect(committedBeforeNotify).toBe(true) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) await Promise.resolve() await Promise.resolve() @@ -1422,7 +1441,7 @@ describe('SessionStore', () => { const [observedSession, event] = args as [Session, SessionEvent] validations.push({ event, - logLength: observedSession.events.length, + logLength: observedSession.snapshotEvents().length, frozen: Object.isFrozen(event) && Object.isFrozen(event.data), }) if (reject) { @@ -1435,7 +1454,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('reject first candidate') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) const appended = session.append('turn/start', { @@ -1447,7 +1466,7 @@ describe('SessionStore', () => { ]) expect(validations.map(({ event }) => event.seq)).toEqual([0, 0]) expect(validations[1]!.event).toBe(appended) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(observed).toEqual([appended]) }) @@ -1486,7 +1505,7 @@ describe('SessionStore', () => { sourceEventSeqs: [2], })).toThrow('reject surface candidate') - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) expect(surface.nodes).toEqual([2]) expect(surface.replaceGeneration).toBe(0) @@ -1511,7 +1530,7 @@ describe('SessionStore', () => { expect(() => session.append('turn/start', { turn: 1, })).toThrow('dispatch instrumentation rejected the carrier') - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) expect(observed).toEqual([]) }) @@ -1530,7 +1549,7 @@ describe('SessionStore', () => { const appended = session.append('turn/start', { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(heard).toEqual([appended]) expect(warnings).toEqual([ 'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published', @@ -1561,7 +1580,7 @@ describe('SessionStore', () => { turn: 1, }) - expect(session.events).toEqual([appended]) + expect(session.snapshotEvents()).toEqual([appended]) expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached']) expect(ctx.sessions.get(session.id)).toBeUndefined() }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 4be69465c3..60ce0ebd54 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -318,7 +318,7 @@ describe('SurfaceManager', () => { }), }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] }) - const folded = foldSurface(s.events) + const folded = foldSurface(s.snapshotEvents()) expect(folded.nodes).toEqual(s.surface.nodes) expect(folded.replacements).toEqual([ { seq: 2, start: 0, end: 0, shadowedSeqs: [0] }, @@ -327,8 +327,8 @@ describe('SurfaceManager', () => { folded.nodes[0] = 99 folded.replacements[0]!.shadowedSeqs.push(99) expect(s.surface.nodes).toEqual([3]) - expect(foldSurface(s.events).nodes).toEqual([3]) - expect(foldSurface(s.events).replacements[0]!.shadowedSeqs).toEqual([0]) + expect(foldSurface(s.snapshotEvents()).nodes).toEqual([3]) + expect(foldSurface(s.snapshotEvents()).replacements[0]!.shadowedSeqs).toEqual([0]) }) it('does not retain fold-only replacement history in incremental state', () => { @@ -351,7 +351,7 @@ describe('SurfaceManager', () => { expect(s.surface.nodes).toEqual([1]) const manager = s.surface as unknown as { _state: object } expect(Object.hasOwn(manager._state, 'replacements')).toBe(false) - expect(foldSurface(s.events).replacements).toEqual([ + expect(foldSurface(s.snapshotEvents()).replacements).toEqual([ { seq: 1, start: 0, end: 0, shadowedSeqs: [0] }, ]) }) @@ -375,7 +375,7 @@ describe('SurfaceManager', () => { const surface = s.surface const nodes = surface.nodes - expect(nodes).toEqual(foldSurface(s.events).nodes) + expect(nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) expect(surface.replaceGeneration).toBe(0) expect(() => s.append( @@ -394,11 +394,11 @@ describe('SurfaceManager', () => { { surfaceOp: { op: 'replace', start: 0, end: 0 } }, )).toThrow(/missing 0/) - expect(s.events).toHaveLength(1) + expect(s.snapshotEvents()).toHaveLength(1) expect(s.surface).toBe(surface) expect(surface.nodes).toEqual([0]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'b' }], source: { kind: 'user' }, @@ -406,7 +406,7 @@ describe('SurfaceManager', () => { expect(surface.nodes).toBe(nodes) expect(surface.nodes).toEqual([0, 1]) expect(surface.replaceGeneration).toBe(0) - expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.nodes).toEqual(foldSurface(s.snapshotEvents()).nodes) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { @@ -479,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = Session.create(SessionId('replay'), [...original.events]) + const replayed = Session.create(SessionId('replay'), [...original.snapshotEvents()]) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) @@ -645,7 +645,7 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(1) sources[0] = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([0]) }) @@ -697,7 +697,7 @@ describe('SurfaceManager', () => { }, { surfaceOp: op, sourceEventSeqs: [0] }) // Mutate caller's object after append. op.start = 99 - const logged = s.events[1]! as SurfaceEvent + const logged = s.snapshotEvents()[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) }) @@ -797,8 +797,8 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) - expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append') + expect((s.snapshotEvents()[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((s.snapshotEvents()[2]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => { @@ -830,8 +830,8 @@ describe('Session.append surface opts', () => { it('a non-surface event carries no surface fields', () => { const s = Session.create(SessionId('noopts')) s.append('turn/start', { turn: 1 }) - expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() - expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).sourceEventSeqs).toBeUndefined() + expect((s.snapshotEvents()[0] as SessionEvent).surfaceOp).toBeUndefined() }) it('surfaceOp primitives are not cloned (they are immutable)', () => { @@ -882,13 +882,13 @@ describe('surface type guards', () => { it('isSurfaceEvent narrows a fully-formed surface event', () => { const s = surfaceSession() - const userMessage = s.events.find(e => e.type === 'user/message')! + const userMessage = s.snapshotEvents().find(e => e.type === 'user/message')! expect(isSurfaceEvent(userMessage)).toBe(true) }) it('isSurfaceEvent rejects a non-surface-eligible type', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! expect(isSurfaceEvent(turnStart)).toBe(false) }) @@ -913,8 +913,8 @@ describe('surface type guards', () => { s.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' }, }), { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - const appended = s.events.find(e => e.type === 'user/message')! - const replacement = s.events.at(-1)! + const appended = s.snapshotEvents().find(e => e.type === 'user/message')! + const replacement = s.snapshotEvents().at(-1)! expect(isAppendSurfaceEvent(appended)).toBe(true) expect(isReplacementSurfaceEvent(appended)).toBe(false) @@ -924,7 +924,7 @@ describe('surface type guards', () => { it('rejects log-only and markerless events from both marker guards', () => { const s = surfaceSession() - const turnStart = s.events.find(e => e.type === 'turn/start')! + const turnStart = s.snapshotEvents().find(e => e.type === 'turn/start')! // A surface-eligible type whose mandatory marker is absent has no origin at // all: it never entered the surface. const markerless: SessionEvent = { diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 5489f61d80..539fc43373 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -58,7 +58,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): number | null => { let openTurn: number | null = null dispatchRoots.set(session, new Map()) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateDispatch(session, event) commitDispatch(session, event) if (event.type === 'turn/start') openTurn = event.data.turn diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index db3c0b905c..c68ae14073 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -152,7 +152,7 @@ describe('tool-pipeline invariants', () => { name: 'echo', arguments: {}, })).toThrow(/parentCallId child does not belong to rootCallId another-root/) - expect(session.events.some(event => event.type === 'tool/code-dispatch-start' + expect(session.snapshotEvents().some(event => event.type === 'tool/code-dispatch-start' && String(event.data.subCallId) === 'invalid-grandchild')).toBe(false) }) diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 6a29701aa4..dfeb911843 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -1483,7 +1483,7 @@ describe('the run_code dispatch bridge', () => { expect(result.isError).toBe(false) expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth }) expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' }) - const dispatch = session.events.find(event => event.type === 'tool/code-dispatch') + const dispatch = session.snapshotEvents().find(event => event.type === 'tool/code-dispatch') if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event') const logged = dispatch.data.arguments as { nested: JsonValue } let loggedDepth = 0 diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 94d16b50fe..3b7a8642b7 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -254,11 +254,11 @@ describe('dsh-agent-spine-demo bundle', () => { await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) - const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry') + const retryEvents = handle.agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(retryEvents).toHaveLength(1) expect(retryEvents[0]?.data.retry).toBe(1) expect(retryEvents[0]?.data).toMatchObject({ provider: 'mock', mode: 'normal', maxRetries: 1 }) - expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover') + expect(handle.agent.session.snapshotEvents().find(event => event.type === 'session/title')?.data.title).toBe('recover') expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy') await handle.dispose() await ctx.fiber.dispose() @@ -512,7 +512,7 @@ describe('dsh-agent-spine-demo bundle', () => { expect(loadedRequest).toContain('') expect(loadedRequest).toContain('Use the freshly loaded body.') - const transcript = handle.agent.session.events.flatMap>((event) => { + const transcript = handle.agent.session.snapshotEvents().flatMap>((event) => { if (event.type === 'user/message' && event.data.source.kind === 'skill-catalog') { return [{ type: event.type, diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index 436e494fb1..aa1e972673 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -306,7 +306,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) } diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 907df82daa..f975c11d68 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -373,7 +373,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) - const suffix = session.events.slice(session.header.seedLength ?? 0) + const suffix = session.snapshotEvents(session.header.seedLength ?? 0) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue await progress.promise @@ -480,6 +480,6 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { - return foldSubagentDescriptor(agent.session.events.slice(agent.session.header.seedLength ?? 0)) !== undefined + return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.header.seedLength ?? 0)) !== undefined } } diff --git a/packages/experimental/agent-team/tests/invariant.spec.ts b/packages/experimental/agent-team/tests/invariant.spec.ts index ba12a07c74..04d99d75a5 100644 --- a/packages/experimental/agent-team/tests/invariant.spec.ts +++ b/packages/experimental/agent-team/tests/invariant.spec.ts @@ -44,7 +44,7 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(invalid.events).toEqual([]) + expect(invalid.snapshotEvents()).toEqual([]) }) it('rejects an invalid task dependency before publication', async () => { @@ -69,6 +69,6 @@ describe('Agent Teams stream invariant', () => { code: 'INVARIANT', packageName: '@deepseek-ai/dsh-experimental-agent-team', })) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 88e72e5506..b3e63c557c 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -32,7 +32,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 21d0e61b4a..5596dde448 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -35,7 +35,7 @@ function durable(agent: Agent): { pendingMessages: TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) - for (const event of agent.session.events) projected = teamProjectionDefinition.apply(projected, event) + for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) if (projected.failure !== undefined) throw new Error(projected.failure) const state = projected return { @@ -456,7 +456,7 @@ describe('Team identity and provisioning', () => { await ctx.agentTeams.createTask(lead, { subject: 'parent task', description: 'belongs to parent' }) const handle = await ctx.agents.create({ sessionId: SessionId('ordinary-fork'), - seed: lead.session.events, + seed: lead.session.snapshotEvents(), meta: { parentSession: lead.id, seedLength: lead.session.seq }, agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -979,13 +979,13 @@ describe('Team mailbox and waiting', () => { 'team/message/delivered', ]) - const receiptCount = lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + const receiptCount = lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId))).length await teamFiber.dispose() await ctx.plugin(TeamService, { maxPendingMessagesPerMember: 1 }) await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) }) - expect(lead.session.events.filter(event => event.type === 'agent/inbox/spliced' + expect(lead.session.snapshotEvents().filter(event => event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => message.source.kind === 'team-message' && messageIds.has(message.source.messageId)))).toHaveLength(receiptCount) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 28e8d3dc06..0730e5f8b4 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4740,7 +4740,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n readonly seeded: boolean;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAddress', diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6fa539beb6..647eeb3da4 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -94,7 +94,7 @@ async function run(test: Harness, suffix = ''): Promise<{ kind: string; text?: s /** Authoritative feedback payloads in log order. */ function feedbackTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'feedback/record') .map(event => event.data.text) } @@ -128,15 +128,15 @@ describe('/feedback human command', () => { text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) - const commandRun = test.session.events.find(event => event.type === 'command/run') + const commandRun = test.session.snapshotEvents().find(event => event.type === 'command/run') expect(commandRun?.type === 'command/run' && Object.hasOwn(commandRun.data, 'args')).toBe(false) - expect(JSON.stringify(test.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(test.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) }) it('exports a command-independent feedback producer', async () => { const test = await harness() commandFeedback.recordFeedback(test.session, ' recorded outside a command ') - expect(test.session.events.map(event => event.type)).toEqual(['feedback/record']) + expect(test.session.snapshotEvents().map(event => event.type)).toEqual(['feedback/record']) expect(feedbackTexts(test.session)).toEqual(['recorded outside a command']) expect(() => { commandFeedback.recordFeedback(test.session, ' \n\t ') }) .toThrow('feedback text must not be empty') @@ -146,7 +146,7 @@ describe('/feedback human command', () => { it('keeps command bookkeeping around the authoritative feedback event', async () => { const test = await harness() await run(test, ' nothing else happens') - expect(test.session.events.map(event => event.type)).toEqual([ + expect(test.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'feedback/record', 'command/done', ]) }) @@ -209,11 +209,11 @@ describe('/feedback human command', () => { it('keeps every recorded event out of model context and derived history', async () => { const test = await harness() await run(test, ' invisible to the model') - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { expect('surfaceOp' in event).toBe(false) expect(test.session.deriveEventMessage(event)).toBeNull() } - expect(foldSurface(test.session.events).nodes).toEqual([]) + expect(foldSurface(test.session.snapshotEvents()).nodes).toEqual([]) expect(test.session.surface.nodes).toEqual([]) expect(test.session.deriveMessages()).toEqual([]) }) @@ -228,9 +228,9 @@ describe('/feedback human command', () => { await expect(run(test, ' \n\t ')).resolves.toEqual(expected) expect(getOrCreateAnonymousUserId).not.toHaveBeenCalled() expect(feedbackTexts(test.session)).toEqual([]) - const done = test.session.events.filter(event => event.type === 'command/done') + const done = test.session.snapshotEvents().filter(event => event.type === 'command/done') expect(done.map(event => event.data.kind)).toEqual(['error', 'error']) - for (const event of test.session.events) { + for (const event of test.session.snapshotEvents()) { if (event.type === 'command/run') expect(Object.hasOwn(event.data, 'args')).toBe(false) } }) @@ -241,6 +241,6 @@ describe('/feedback human command', () => { controller.abort(new Error('user cancelled the command')) await expect(test.ctx.commands.execute(test.agent, '/feedback too late', [], controller.signal)) .rejects.toThrow('user cancelled the command') - expect(test.session.events).toEqual([]) + expect(test.session.snapshotEvents()).toEqual([]) }) }) diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index d133d86830..624ca05af5 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -102,13 +102,13 @@ describe('/feedback real Loader composition through cordis.yml', () => { }) // The domain event owns the payload; generic command bookkeeping omits it. - expect(owner.session.events.map(event => event.type)) + expect(owner.session.snapshotEvents().map(event => event.type)) .toEqual(['command/run', 'feedback/record', 'command/done', 'command/run', 'command/done']) - const run = owner.session.events.find(event => event.type === 'command/run') + const run = owner.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) - const feedback = owner.session.events.find(event => event.type === 'feedback/record') + const feedback = owner.session.snapshotEvents().find(event => event.type === 'feedback/record') expect(feedback?.type === 'feedback/record' && feedback.data.text).toBe('the diff view is unreadable') - expect(JSON.stringify(owner.session.events).match(/the diff view is unreadable/gu)).toHaveLength(1) + expect(JSON.stringify(owner.session.snapshotEvents()).match(/the diff view is unreadable/gu)).toHaveLength(1) // Nothing reached the model. expect(owner.session.deriveMessages()).toEqual([]) diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index f653ac6d63..748373856d 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -135,7 +135,7 @@ class TestPersistence extends SessionPersistence { const explicit = this.logical.get(id) if (explicit !== undefined) return Promise.resolve(explicit) const live = this.ctx.sessions.get(id) - if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.snapshotEvents() }) const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) @@ -171,7 +171,7 @@ class TestPersistence extends SessionPersistence { } persist(session: Session): void { - this.durable.set(session.id, { meta: session.header, events: session.events }) + this.durable.set(session.id, { meta: session.header, events: session.snapshotEvents() }) } setDurable(inspection: SessionInspection): void { diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 5c45e6a0bc..82c49a93b7 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -62,7 +62,7 @@ describe('MessageFeedbackService public contract', () => { }) const fixture = messageFixture('corrupt-session') - persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }) const corruption = new Error('stored log checksum mismatch') persistence.inspectFailure = corruption await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) @@ -258,7 +258,7 @@ describe('MessageFeedbackService public contract', () => { rawCtx.provide('sessions', { get: () => undefined } as never) rawCtx.provide('sessionPersistence', { listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), - inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.snapshotEvents() }), } as never) const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) await expect(raw.list({ sessionId: fixture.session.id })) @@ -437,7 +437,7 @@ describe('MessageFeedbackService item concurrency', () => { const replacement = Session.create( old.session.id, - old.session.events, + old.session.snapshotEvents(), { ...old.session.header, createdAt: 20, cwd: '/new' }, ) persistence.persist(replacement) @@ -518,7 +518,7 @@ describe('MessageFeedbackService durability ordering', () => { const fixture = messageFixture('cold-prefix') persistence.logical.set(fixture.session.id, { meta: fixture.session.header, - events: fixture.session.events, + events: fixture.session.snapshotEvents(), }) persistence.setDurable({ meta: fixture.session.header, events: [] }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 387fc2410c..ac422b481f 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => expect(content).not.toContain('draft') // The log records real read/write/edit tool calls (not bash). - const calls = [...agent.session.events].filter(e => e.type === 'tool/call').map(e => e.data.name) + const calls = [...agent.session.snapshotEvents()].filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') expect(calls).toContain('edit') diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 5e50e7fc4e..ecd258f24c 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -810,7 +810,7 @@ describe('sandbox escalation API (write/edit)', () => { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - events: [{ type: 'turn/start', data: { turn: 1 } }, ...events], + snapshotEvents: () => [{ type: 'turn/start', data: { turn: 1 } }, ...events], append: (type: string, data: Record) => { events.push({ type, data }) }, }, } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index ed126cc02e..1f1cdf0753 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -6,7 +6,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' @@ -56,18 +56,18 @@ async function harness(): Promise { } /** The log with executor-owned command lifecycle bookkeeping stripped (goal assertions target domain events). */ -function domainEvents(session: Session): readonly Session['events'][number][] { +function domainEvents(session: Session): readonly SessionEvent[] { const lifecycle = new Set() - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type !== 'command/run' && event.type !== 'command/done') continue lifecycle.add(event.seq) // The zero-step wrap around a lifecycle event is bookkeeping too. - const before = session.events[event.seq - 1] - const after = session.events[event.seq + 1] + const before = session.snapshotEvents()[event.seq - 1] + const after = session.snapshotEvents()[event.seq + 1] if (before?.type === 'turn/start') lifecycle.add(before.seq) if (after?.type === 'turn/end') lifecycle.add(after.seq) } - return session.events.filter(event => !lifecycle.has(event.seq)) + return session.snapshotEvents().filter(event => !lifecycle.has(event.seq)) } /** Execute `/goal` through the same registry boundary as a UI adapter. */ diff --git a/packages/goal/goal-round-driver/src/invariant.ts b/packages/goal/goal-round-driver/src/invariant.ts index 6f072140f4..22ca5c39a1 100644 --- a/packages/goal/goal-round-driver/src/invariant.ts +++ b/packages/goal/goal-round-driver/src/invariant.ts @@ -61,7 +61,7 @@ function validateEvent( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { const prior: SessionEvent[] = [] - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(prior, event, fail) prior.push(event) } @@ -70,7 +70,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - validateEvent(session.events, event, fail) + validateEvent(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index f92db57dbf..d2749378bf 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -199,7 +199,7 @@ describe('same-session goal driving', () => { }) expect(test.adapter.requests).toHaveLength(2) const rounds: number[] = [] - for (const event of test.agent.session.events) { + for (const event of test.agent.session.snapshotEvents()) { // Round zero is a durable goal state change; positive rounds are the // admitted continuation prompts this test counts. if (event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) { @@ -209,7 +209,7 @@ describe('same-session goal driving', () => { expect(rounds).toEqual([1, 2]) expect(requestText(test.adapter.requests[0]!)).toContain('Round: 1/2') expect(requestText(test.adapter.requests[1]!)).toContain('Round: 2/2') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -265,7 +265,7 @@ describe('same-session goal driving', () => { message: 'Goal round was rejected before entering its step.', }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { @@ -302,7 +302,7 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(0) // No admitted continuation round reached the model; goal state changes are // represented by their own durable event. - expect(test.agent.session.events.some(event => event.type === 'user/message' + expect(test.agent.session.snapshotEvents().some(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0)).toBe(false) }) @@ -330,7 +330,7 @@ describe('same-session goal driving', () => { expect(requestText(test.adapter.requests[0]!)).toContain('human goes first') expect(requestText(test.adapter.requests[0]!)).not.toContain('') expect(requestText(test.adapter.requests[1]!)).toContain('') - expect(test.agent.session.events.flatMap(event => + expect(test.agent.session.snapshotEvents().flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) }) @@ -367,7 +367,7 @@ describe('same-session goal driving', () => { const goal = await waitForGoal(test.ctx, test.agent, current => current?.phase === 'blocked') expect(goal).toMatchObject({ revision: 3, objective: 'new objective', roundsStarted: 1 }) - const admitted = test.agent.session.events.find(event => event.type === 'user/message' + const admitted = test.agent.session.snapshotEvents().find(event => event.type === 'user/message' && event.data.source.kind === 'goal' && event.data.source.round > 0) expect(admitted?.type === 'user/message' && admitted.data.source.kind === 'goal' ? admitted.data.source.revision @@ -753,7 +753,7 @@ describe('same-session goal driving', () => { await test.agent.whenIdle() expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('leaves round-zero goal context to the ordinary pre-step chain', async () => { @@ -927,7 +927,7 @@ describe('same-session goal driving', () => { }) handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'one ordinary turn' }], source: { kind: 'user' } })) await handle.agent.whenIdle() - const closed = handle.agent.session.events.findLast(event => event.type === 'turn/end') + const closed = handle.agent.session.snapshotEvents().findLast(event => event.type === 'turn/end') if (closed?.type !== 'turn/end') throw new Error('expected a closed turn') await handle.dispose() const warn = vi.spyOn(test.ctx.logger, 'warn') @@ -1032,7 +1032,7 @@ describe('same-session goal driving', () => { expect(test.ctx.goals.get(test.agent)).toMatchObject({ phase: 'active', roundsStarted: 0 }) expect(test.adapter.requests).toHaveLength(0) - expect(test.agent.session.events.some(event => event.type === 'turn/start')).toBe(true) + expect(test.agent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(true) }) it('ignores session events without an exact owning agent and retires disposed agent state', async () => { diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 31ac8e2f67..3d8e6a46f0 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -43,7 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): GoalFoldState => { const state = emptyGoalFoldState() - for (const event of session.events) applyChecked(state, event, fail) + for (const event of session.snapshotEvents()) applyChecked(state, event, fail) states.set(session, state) return state } diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 7deb195d19..503265bab4 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -20,7 +20,7 @@ interface StubAgent { /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { - return session.events.reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 + return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 } /** Mirror the public Agent.inject contract for domain tests. */ @@ -109,8 +109,8 @@ describe('GoalService creation and replay', () => { }) expect(goal.id).toMatch(/^goal-/) expect(seen).toEqual(['create']) - expect(session.events.map(event => event.type)).toEqual(['goal/change']) - const context = session.events[0] + expect(session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) + const context = session.snapshotEvents()[0] expect(context?.type).toBe('goal/change') if (context?.type !== 'goal/change') throw new Error('expected durable goal change') const change = decodeGoalChange(context.data) @@ -118,7 +118,7 @@ describe('GoalService creation and replay', () => { expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } }) expect(agent.inbox.nextStep).toEqual([]) expect(session.deriveMessages()).toEqual([]) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 }) vi.useRealTimers() }) @@ -170,7 +170,7 @@ describe('GoalService creation and replay', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) - const resumed = stubAgent('seeded-goal', first.session.events) + const resumed = stubAgent('seeded-goal', first.session.snapshotEvents()) ctx.agents.register(resumed.agent) expect(ctx.goals.get(resumed.agent)).toMatchObject({ id: created.id, @@ -210,20 +210,20 @@ describe('GoalService creation and replay', () => { expect(ctx.goals.get(agent)?.activation).toBe('disarmed') goal = ctx.goals.resume(agent, goal) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() }) it('lets a lifecycle owner disarm without writing a durable revision', async () => { const { ctx, agent, session } = await harness() const goal = ctx.goals.create(agent, { objective: 'survive driver reload' }) - const before = session.events.length + const before = session.snapshotEvents().length expect(ctx.goals.disarm(agent)).toMatchObject({ id: goal.id, revision: goal.revision, phase: 'active', activation: 'disarmed', }) - expect(session.events).toHaveLength(before) + expect(session.snapshotEvents()).toHaveLength(before) expect(ctx.goals.resume(agent, goal)).toMatchObject({ revision: 2, activation: 'armed' }) }) @@ -385,7 +385,7 @@ describe('GoalService mutations', () => { const tombstone = ctx.goals.clear(agent, goal) expect(tombstone).toEqual({ id: goal.id, revision: 2 }) expect(ctx.goals.get(agent)).toBeUndefined() - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0, lastRef: tombstone }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: tombstone }) expect(() => ctx.goals.clear(agent, goal)).toThrow(expect.objectContaining({ code: 'GOAL_NOT_FOUND' })) const next = ctx.goals.create(agent, { objective: 'fresh' }) expect(next.id).not.toBe(goal.id) @@ -401,12 +401,12 @@ describe('GoalService mutations', () => { expect(goal.updatedAt).toBe(100) vi.setSystemTime(80) ctx.goals.clear(agent, goal) - const clear = session.events + const clear = session.snapshotEvents() .filter(event => event.type === 'goal/change') .map(event => event.type === 'goal/change' ? decodeGoalChange(event.data) : undefined) .at(-1) expect(clear).toMatchObject({ operation: 'clear', clearedAt: 100 }) - expect(() => foldGoal(session.events)).not.toThrow() + expect(() => foldGoal(session.snapshotEvents())).not.toThrow() vi.useRealTimers() }) @@ -427,11 +427,11 @@ describe('GoalService mutations', () => { goal = ctx.goals.edit(agent, goal, { objective: 'deferred edit' }) goal = ctx.goals.pause(agent, goal) expect(goal).toMatchObject({ revision: 3, phase: 'paused', activation: 'disarmed' }) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'goal/change', 'goal/change', 'goal/change', ]) expect(ctx.goals.get(agent)).toMatchObject({ revision: 3, phase: 'paused' }) - expect(foldGoal(session.events)).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { revision: 3, phase: 'paused' } }) }) it('publishes a mutation consistently to a reentrant session observer', async () => { @@ -451,7 +451,7 @@ describe('GoalService mutations', () => { expect(observed).toEqual(created) expect(ctx.goals.get(stub.agent)).toEqual(created) - expect(foldGoal(stub.session.events)).toMatchObject({ goal: { id: created.id, revision: 1 } }) + expect(foldGoal(stub.session.snapshotEvents())).toMatchObject({ goal: { id: created.id, revision: 1 } }) }) it('does not delegate goal persistence to agent injection', async () => { @@ -468,7 +468,7 @@ describe('GoalService mutations', () => { revision: 1, }) expect(stub.agent.inbox.nextStep).toEqual([]) - expect(stub.session.events.map(event => event.type)).toEqual(['goal/change']) + expect(stub.session.snapshotEvents().map(event => event.type)).toEqual(['goal/change']) }) it('observes an external goal change and disarms local activation', async () => { @@ -528,7 +528,7 @@ describe('goal replay validation', () => { function oneChange(change: GoalChangeMeta) { const session = Session.create(SessionId(`validation-${Math.random()}`)) appendChange(session, change) - return session.events + return session.snapshotEvents() } function mutation( @@ -559,7 +559,7 @@ describe('goal replay validation', () => { const change = snapshotChange() const session = Session.create(SessionId('inbox-independent-change')) appendChange(session, change) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) const message = createUserMessage({ content: [{ type: 'text', text: 'unrelated pending context' }], source: { kind: 'plugin', plugin: 'test' }, @@ -567,14 +567,14 @@ describe('goal replay validation', () => { const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) - expect(foldGoal(session.events)).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) + expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) }) function foldPair(first: GoalSnapshotChangeMeta, second: GoalChangeMeta): ReturnType { const session = Session.create(SessionId(`validation-pair-${Math.random()}`)) appendChange(session, first) appendChange(session, second) - return foldGoal(session.events) + return foldGoal(session.snapshotEvents()) } it('ignores unrelated metadata and non-goal round sources', () => { @@ -585,7 +585,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'other' }], source: { kind: 'plugin', plugin: 'test' }, })) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) const source = { kind: 'plugin', plugin: 'ordinary-user-message' } as const const turn = nextTurn(session) session.append('turn/start', { turn }) @@ -593,14 +593,14 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'ordinary' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(foldGoal(session.events)).toEqual({ roundsStarted: 0 }) + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0 }) }) it('rejects rounds attributed to another goal', () => { const change = snapshotChange() const session = Session.create(SessionId('other-goal-round'), oneChange(change)) appendRound(session, { id: GoalId('goal-other'), revision: 1 }, 1) - expect(() => foldGoal(session.events)).toThrow('not the next admitted round') + expect(() => foldGoal(session.snapshotEvents())).toThrow('not the next admitted round') }) it('rejects unsupported versions, operations, and extra top-level fields', () => { @@ -676,7 +676,7 @@ describe('goal replay validation', () => { appendRound(session, base.goal, 2) appendChange(session, { ...paused, roundsStarted: 2 }) appendChange(session, exhausted) - expect(() => foldGoal(session.events)).toThrow('exhausted round budget') + expect(() => foldGoal(session.snapshotEvents())).toThrow('exhausted round budget') }) it('rejects invalid clear continuity and goal id reuse', () => { @@ -700,7 +700,7 @@ describe('goal replay validation', () => { appendChange(completedSession, base) appendChange(completedSession, complete) appendChange(completedSession, sameCurrentId) - expect(() => foldGoal(completedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(completedSession.snapshotEvents())).toThrow('fresh active revision-one') const second = snapshotChange({ goal: { ...base.goal, id: GoalId('goal-second') }, @@ -714,7 +714,7 @@ describe('goal replay validation', () => { appendChange(nonAdjacentReuse, second) appendChange(nonAdjacentReuse, secondComplete) appendChange(nonAdjacentReuse, { ...sameCurrentId, createdAt: 30, updatedAt: 30 }) - expect(() => foldGoal(nonAdjacentReuse.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(nonAdjacentReuse.snapshotEvents())).toThrow('fresh active revision-one') const clear: GoalChangeMeta = { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: base.goal.id, revision: 2 }, clearedAt: 11, @@ -723,7 +723,7 @@ describe('goal replay validation', () => { appendChange(clearedSession, base) appendChange(clearedSession, clear) appendChange(clearedSession, sameCurrentId) - expect(() => foldGoal(clearedSession.events)).toThrow('fresh active revision-one') + expect(() => foldGoal(clearedSession.snapshotEvents())).toThrow('fresh active revision-one') }) it('rejects non-positive goal round sources', () => { @@ -735,7 +735,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'missing' }], source, }), { surfaceOp: 'append' }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) - expect(() => foldGoal(session.events)).toThrow('goal message source is invalid') + expect(() => foldGoal(session.snapshotEvents())).toThrow('goal message source is invalid') }) it('rejects malformed snapshots, refs, counters, and timestamps', () => { @@ -782,7 +782,7 @@ describe('goal replay validation', () => { clearedAt: 20, } appendChange(session, clear) - expect(foldGoal(session.events)).toEqual({ + expect(foldGoal(session.snapshotEvents())).toEqual({ roundsStarted: 0, lastRef: { id: change.goal.id, revision: 2 }, }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index f9ef917584..4dd473db31 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -136,7 +136,7 @@ describe('goal projection unit', () => { })) expect(bench.tailValues().goal).toBeNull() - expect(foldGoal(bench.session.events).goal).toBeUndefined() + expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() }) it('retains strict replay failures without throwing from the projection drive', () => { diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index bc4ae8faaf..29a820c8df 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -30,7 +30,7 @@ function openTurnEvents( ctx: Context, agent: Agent, ): Pick { - const events = agent.session.events + const events = agent.session.snapshotEvents() const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary === undefined || boundary.openTurnStartSeq === null) { reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 86765f87c8..ab544d1b9c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -49,7 +49,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { /** Open one message-triggered turn with its accepted model-visible input. */ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): number { - const turn = stub.session.events + const turn = stub.session.snapshotEvents() .filter(event => event.type === 'turn/start') .reduce((max, event) => Math.max(max, event.data.turn), 0) + 1 const message = createUserMessage({ @@ -262,7 +262,7 @@ describe('goal tool execution authority', () => { const created = ctx.goals.create(root.agent, { objective: 'resume the fork' }) closeTurn(root, originalTurn) const forkId = SessionId('goal-tool-resumed-fork') - const forkSession = Session.create(forkId, root.session.events, { + const forkSession = Session.create(forkId, root.session.snapshotEvents(), { version: SESSION_FORMAT_VERSION, id: forkId, createdAt: Date.now(), diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index 58369c16b7..b324c85096 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), @@ -340,7 +340,7 @@ describe('fold onto the downstream decision', () => { expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.content[0].isError)).toBe(true) expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }]) }) @@ -364,7 +364,7 @@ describe('fold onto the downstream decision', () => { const found = reminders(agent) expect(found).toHaveLength(1) expect(found[0]!.text).toContain('repeating the exact same tool call') - const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }]) }) }) diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index cee3331842..660241f8fd 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -74,7 +74,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): HookTrace => { const trace: HookTrace = { openTurn: null, pending: new Map() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateHookEvent(trace, event, fail) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 18f1383938..a402f8ae02 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) @@ -25,7 +25,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - const ev = [...session.events].find(e => e.type === 'hook/invoked') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) } @@ -37,7 +37,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - const full = [...session.events].find(e => e.type === 'hook/result') + const full = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } @@ -48,7 +48,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - const sparse = [...session2.events].find(e => e.type === 'hook/result') + const sparse = [...session2.snapshotEvents()].find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) @@ -63,7 +63,7 @@ describe('hook/* session events', () => { // An explicit decision wins over the continue:false fallback. appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) - const decisions = [...session.events] + const decisions = [...session.snapshotEvents()] .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) @@ -75,7 +75,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') } @@ -87,7 +87,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - const ev = [...session.events].find(e => e.type === 'hook/result') + const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } @@ -98,8 +98,8 @@ describe('hook/* session events', () => { appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) - const invoked = [...session.events].find(e => e.type === 'hook/invoked') - const result = [...session.events].find(e => e.type === 'hook/result') + const invoked = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const result = [...session.snapshotEvents()].find(e => e.type === 'hook/result') expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index af323d9b63..b92900e01c 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -73,7 +73,7 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 4be08bb348..a6e032d353 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -54,7 +54,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index bac95b7eed..200de375ba 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -56,7 +56,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 7fed07518a..6aad28fff0 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -44,7 +44,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index ff5d60a7cb..7bce241224 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -35,7 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } const source = event.data.sourceEventSeq - const sourceEvent = source === undefined ? undefined : session.events[source] + const sourceEvent = source === undefined ? undefined : session.eventAt(source) if (source !== undefined && (event.data.kind !== 'success' || !Number.isSafeInteger(source) || source < 0 || source >= event.seq @@ -46,7 +46,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } } for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(session, event) + for (const event of session.snapshotEvents()) validateEvent(session, event) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index a95ee024dc..655cdc5462 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -33,7 +33,7 @@ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scop /** The lifecycle slice of one agent's log (boundary markers stripped). */ function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> { - return agent.session.events + return agent.session.snapshotEvents() .filter(event => event.type === 'command/run' || event.type === 'command/done') .map(event => ({ type: event.type, data: event.data })) } @@ -316,7 +316,7 @@ describe('CommandRuntime', () => { // The execution's pairing id is the logged one (RPC-level correlation). expect(execution?.commandId).toBe(ids[0]) // Direct log-only appends: no turn is opened for the pair on an idle log. - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'command/run', 'command/done', ]) }) @@ -354,7 +354,7 @@ describe('CommandRuntime', () => { await ctx.commands.execute(agent, '/private keep this once', [], new AbortController().signal) expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' keep this once' })) - const run = agent.session.events.find(event => event.type === 'command/run') + const run = agent.session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.type).toBe('command/run') expect(run?.type === 'command/run' && Object.hasOwn(run.data, 'args')).toBe(false) }) @@ -428,7 +428,7 @@ describe('CommandRuntime', () => { const signal = new AbortController().signal await ctx.commands.execute(agent, 'not a command', [], signal) await ctx.commands.execute(agent, '/missing', [], signal) - expect(agent.session.events).toEqual([]) + expect(agent.session.snapshotEvents()).toEqual([]) }) it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => { @@ -437,7 +437,7 @@ describe('CommandRuntime', () => { ctx.commands.register(command('mid')) agent.session.append('turn/start', { turn: 1 }) await ctx.commands.execute(agent, '/mid', [], new AbortController().signal) - expect(agent.session.events.map(event => event.type)).toEqual([ + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'command/run', 'command/done', ]) }) diff --git a/packages/interaction/permission-presets/src/invariant.ts b/packages/interaction/permission-presets/src/invariant.ts index b6f66adb1c..dee9db2451 100644 --- a/packages/interaction/permission-presets/src/invariant.ts +++ b/packages/interaction/permission-presets/src/invariant.ts @@ -21,7 +21,7 @@ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure /** Install validation that loaded and newly appended preset events remain resolvable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(ctx, event, fail) + for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 065a7ea155..3da5749e2b 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -151,7 +151,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') - expect(session.events.map(e => [e.type, e.data])).toEqual([ + expect(session.snapshotEvents().map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], @@ -162,7 +162,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted() const session = freshSession('sess-noop') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) }) it('re-asserting a preset from a drifted (custom) state re-records the choice and repairs the knob', async () => { @@ -173,7 +173,7 @@ describe('PermissionPresetService', () => { // the changed knob. session.append('sandbox/mode', { mode: 'read-only' }) ctx.permissionPresets.set(session, 'danger-full-access') - const tail = session.events.slice(4) + const tail = session.snapshotEvents().slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ ['permission/preset', { preset: 'danger-full-access' }], ['sandbox/mode', { mode: 'danger-full-access' }], @@ -208,7 +208,7 @@ describe('PermissionPresetService', () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') ctx.permissionPresets.set(session, 'workspace-write') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) expect(ctx.permissionPresets.current(session)).toBe('workspace-write') }) }) @@ -217,7 +217,7 @@ describe('new-session default', () => { it('pins the current setting into each new session without changing earlier sessions', async () => { const ctx = await mountedStore() const first = ctx.sessions.create(SessionId('first')) - expect(first.events.map(event => [event.type, event.data])).toEqual([ + expect(first.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['permission/preset', { preset: 'workspace-write' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], @@ -230,7 +230,7 @@ describe('new-session default', () => { const second = ctx.sessions.create(SessionId('second')) expect(ctx.permissionPresets.current(first)).toBe('workspace-write') expect(ctx.permissionPresets.current(second)).toBe('danger-full-access') - expect(second.events.map(event => event.type)).toEqual([ + expect(second.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -243,9 +243,9 @@ describe('new-session default', () => { const legacy = freshSession('legacy-source') legacy.append('turn/start', { turn: 1 }) legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.snapshotEvents() }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().slice(-3).map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -257,7 +257,7 @@ describe('new-session default', () => { }) const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write') - expect(resumed.events.map(event => event.type)).toEqual([ + expect(resumed.snapshotEvents().map(event => event.type)).toEqual([ 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', ]) }) @@ -274,10 +274,10 @@ describe('new-session default', () => { }) ctx.provide('approval', { config: { policy: 'ask' } }) const existing = ctx.sessions.create(SessionId('existing-before-permission')) - expect(existing.events).toEqual([]) + expect(existing.snapshotEvents()).toEqual([]) await ctx.plugin(PermissionPresetService, {}) - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'permission/preset', 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe('workspace-write') @@ -302,7 +302,7 @@ describe('new-session default', () => { // The remount sweep must read the folded knob events instead of treating // the session as fresh; no default preset events may overwrite the // overrides (read-only + never matches no preset table entry). - expect(existing.events.map(event => event.type)).toEqual([ + expect(existing.snapshotEvents().map(event => event.type)).toEqual([ 'sandbox/mode', 'approval/policy', ]) expect(ctx.permissionPresets.current(existing)).toBe(CUSTOM_PRESET) @@ -313,8 +313,8 @@ describe('new-session default', () => { const partial = freshSession('partial-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) partial.append('approval/policy', { policy: 'ask' }) - const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'permission/preset', data: { preset: 'workspace-write' }, }) @@ -322,17 +322,17 @@ describe('new-session default', () => { const custom = freshSession('custom-source') custom.append('sandbox/mode', { mode: 'read-only' }) custom.append('approval/policy', { policy: 'never' }) - const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.snapshotEvents() }) expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET) - expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + expect(unmatched.snapshotEvents().at(-1)?.type).toBe('session/end-seed') }) it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { const ctx = await mountedStore({ approvalDefault: undefined }) const partial = freshSession('approval-fallback-source') partial.append('sandbox/mode', { mode: 'workspace-write' }) - const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) - expect(resumed.events.at(-1)).toMatchObject({ + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.snapshotEvents() }) + expect(resumed.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' }, }) diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts index f382d4033d..53022e9089 100644 --- a/packages/interaction/permission-presets/tests/projection.spec.ts +++ b/packages/interaction/permission-presets/tests/projection.spec.ts @@ -99,7 +99,7 @@ describe('/permission command', () => { text: 'The approval policy changed from "ask" to "never" (changed by the user).', }], }) - const run = session.events.find(event => event.type === 'command/run') + const run = session.snapshotEvents().find(event => event.type === 'command/run') expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' }) }) @@ -111,13 +111,13 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const { agent } = await agentFor(ctx, session) - const before = session.events.filter(event => + const before = session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', [], new AbortController().signal) // The error text carries the same no-self-labelling rule as the success @@ -127,7 +127,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => + expect(session.snapshotEvents().filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 2608643ca0..93c4a605ab 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -221,7 +221,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.events)) { + if (!hasOpenTurn(session.snapshotEvents())) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -257,7 +257,7 @@ export class ApprovalService extends Service { * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.events) + return effectiveApprovalPolicy(session.snapshotEvents()) } /** diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index bf3ca8d18d..8ff7a9953e 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -64,7 +64,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): ApprovalTrace => { const trace: ApprovalTrace = { openTurn: null, pending: new Set() } traces.set(session, trace) - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null const transition = validateApprovalEvent(trace, event, fail) diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index eb0484a1db..868ba4f384 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -11,7 +11,7 @@ import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPol /** * A minimal Agent stand-in — the service only reaches `agent.session.append` - * and folds `.events`. Seeded inside an open turn by default (request()'s + * and folds `snapshotEvents()`. Seeded inside an open turn by default (request()'s * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ @@ -19,7 +19,7 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty const appended: Array<{ type: string; data: Record }> = [] const agent = { session: { - events: seed, + snapshotEvents: () => seed, append: (type: string, data: Record) => { appended.push({ type, data }) return { type, data } as unknown as SessionEvent @@ -128,9 +128,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data.id).toBe(asked?.data.id) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append')) @@ -151,9 +151,9 @@ describe('ApprovalService.request', () => { await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected') - const audit = session.events.filter(event => event.type.startsWith('approval/')) - const asked = session.events.find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') - const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') + const audit = session.snapshotEvents().filter(event => event.type.startsWith('approval/')) + const asked = session.snapshotEvents().find((event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked') + const decided = session.snapshotEvents().find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided') expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided']) expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append')) @@ -164,7 +164,7 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - events: [{ type: 'turn/start' }], + snapshotEvents: () => [{ type: 'turn/start' }], append: () => { throw failure }, }, } as unknown as Agent @@ -366,11 +366,11 @@ describe('approval policy (the approval/policy fold)', () => { it('folds to the last event, or undefined without one', () => { const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.events)).toBeUndefined() + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.events)).toBe('ask') - expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('ask') + expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) it('rejects a policy outside the closed vocabulary before appending', () => { @@ -409,8 +409,8 @@ describe('approval policy (the approval/policy fold)', () => { await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected') expect(consulted).not.toHaveBeenCalled() // The audit pair still lands on the session log. - expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1) - expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/asked')).toHaveLength(1) + expect(session.snapshotEvents().filter(e => e.type === 'approval/decided')).toHaveLength(1) }) it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => { @@ -458,7 +458,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.events)).toBe('never') + expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 7b454bcc4e..8655f6d661 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -147,9 +147,10 @@ function validateStarted( /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const [index, event] of session.events.entries()) { - if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events.slice(0, index), event, fail) + const events = session.snapshotEvents() + for (const [index, event] of events.entries()) { + if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(events.slice(0, index), event, fail) } } @@ -160,8 +161,8 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - if (event.type === 'llm/retry') validateRetry(session.events, event, fail) - else if (event.type === 'llm/retry-started') validateStarted(session.events, event, fail) + if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail) + else if (event.type === 'llm/retry-started') validateStarted(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index b4bef4c239..1ce91a8b29 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -112,7 +112,7 @@ describe('real Loader composition', () => { await agent.whenIdle() expect(adapter.requests).toBe(2) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', content: [{ type: 'text', text: 'recovered' }], diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 30ffd662e9..1516ff20a4 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -215,7 +215,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data)) + expect(agent.session.snapshotEvents().filter(item => item.type === 'step/start').map(item => item.data)) .toEqual([{ turn: 1, step: 1 }]) expect(agent.session.deriveMessages().at(-1)).toEqual({ id: expect.any(String) as unknown, @@ -250,7 +250,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message').map(event => ({ turn: event.data.turn, step: event.data.step, }))).toEqual([{ turn: 1, step: 1 }]) @@ -286,14 +286,14 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(500) await idle - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - const failedChunks = agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + const failedChunks = agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, ) expect(failedChunks).toHaveLength(7) - const assistantMessages = agent.session.events.filter(event => event.type === 'assistant/message') + const assistantMessages = agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') expect(assistantMessages.map(event => ({ turn: event.data.turn, step: event.data.step, @@ -301,7 +301,7 @@ describe('provider-routed retry policy', () => { expect(failedChunks.every(event => !assistantMessages[0]?.sourceEventSeqs?.includes(event.seq), )).toBe(true) - expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'tool/call')).toBe(false) expect(toolExecutions).toBe(0) expect(agent.session.deriveMessages().at(-1)).toMatchObject({ role: 'assistant', @@ -338,8 +338,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'busy three', code: 'SERVER' } } }, }) @@ -394,7 +394,7 @@ describe('provider-routed retry policy', () => { rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await rejectedIdle expect(rejected.requests).toHaveLength(1) - expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(rejectedAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('uses local jittered backoff when always mode receives an over-cap Retry-After', async () => { @@ -432,7 +432,7 @@ describe('provider-routed retry policy', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) expect(vi.getTimerCount()).toBe(0) }) @@ -451,8 +451,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(0) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } }, @@ -480,7 +480,7 @@ describe('provider-routed retry policy', () => { const normalIdle = waitForIdle(context, normalAgent) normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })) await normalIdle - expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(normalAgent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) const alwaysAgent = context.agentLoop.create(SessionId('retry-provider-always'), { provider: 'other', @@ -566,7 +566,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests.map(request => request.provider)).toEqual(['mock', 'other', 'other']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => ({ + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => ({ provider: event.data.provider, retry: event.data.retry, }))).toEqual([ @@ -677,7 +677,7 @@ describe('provider-routed retry policy', () => { await vi.runAllTimersAsync() await idle - const events = agent.session.events.filter(event => event.type === 'llm/retry') + const events = agent.session.snapshotEvents().filter(event => event.type === 'llm/retry') expect(adapter.requests).toHaveLength(5) expect(events.map(event => ({ provider: event.data.provider, @@ -721,7 +721,7 @@ describe('provider-routed retry policy', () => { const retriedContext = JSON.stringify(adapter.requests[1]?.messages) expect(retriedContext).not.toContain(diagnostic) expect(retriedContext).not.toContain('discarded partial output') - expect(agent.session.events.some(event => + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry' && event.data.failure.message === diagnostic, )).toBe(true) }) @@ -743,7 +743,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(2) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it.each([ @@ -794,7 +794,7 @@ describe('provider-routed retry policy', () => { await vi.advanceTimersByTimeAsync(60_000) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) @@ -835,7 +835,7 @@ describe('provider-routed retry policy', () => { expect(order[0]).toBe('downstream') expect(order).toEqual(expect.arrayContaining(['disposed', 'idle'])) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) }) it('drains delegated recovery before turn cancellation reaches idle', async () => { @@ -873,7 +873,7 @@ describe('provider-routed retry policy', () => { expect(order).toEqual(['downstream', 'idle']) expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -910,7 +910,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -967,7 +967,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -996,8 +996,8 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'aborted' } }, }) @@ -1020,7 +1020,7 @@ describe('provider-routed retry policy', () => { await idle expect(adapter.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(1) expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 7591f8fdf2..c9b8c7eaca 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -103,10 +103,10 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server).toBeDefined() expect(server?.requests).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'step/start') + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('connected after retry') }) @@ -132,16 +132,16 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - const retryEvent = agent.session.events.find(event => event.type === 'llm/retry') - expect(agent.session.events.filter(event => + const retryEvent = agent.session.snapshotEvents().find(event => event.type === 'llm/retry') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && retryEvent !== undefined && event.seq < retryEvent.seq, )).toHaveLength(failedChunkCount) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TRANSPORT']) expect(finalAssistantText(agent)).toBe('recovered response') }) @@ -161,12 +161,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(server.requests).toHaveLength(2) expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['EMPTY_RESPONSE']) - expect(agent.session.events.filter(event => event.type === 'assistant/message') + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/message') .map(event => [event.data.turn, event.data.step])) .toEqual([[1, 1]]) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) @@ -188,12 +188,12 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(1) - expect(agent.session.events.filter(event => + expect(agent.session.snapshotEvents().filter(event => event.type === 'assistant/chunk' && event.data.turn === 1, )).toHaveLength(3) - expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.at(-1)).toMatchObject({ + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } }, }) @@ -215,7 +215,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) - expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') }, 10_000) @@ -233,9 +233,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) expect(server.requests).toHaveLength(3) - expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1) - expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) - const end = agent.session.events.at(-1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'step/start')).toHaveLength(1) + expect(agent.session.snapshotEvents().filter(event => event.type === 'llm/retry')).toHaveLength(2) + const end = agent.session.snapshotEvents().at(-1) expect(end).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'TRANSPORT' } } }, diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index e33c70b1df..008c19f64a 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -206,9 +206,9 @@ export class TokenMeter extends Service { this.states.set(session, state) } - while (state.consumedEvents < session.events.length) { + while (state.consumedEvents < session.seq) { // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log - const event = session.events[state.consumedEvents]! + const event = session.eventAt(state.consumedEvents)! this._foldEvent(session, state, event) state.consumedEvents += 1 } @@ -314,7 +314,7 @@ export class TokenMeter extends Service { seen.add(seq) // Session construction validates contiguous seqs, and the explicit // earlier-than-assistant check above therefore guarantees existence. - const source = session.events[seq] + const source = session.eventAt(seq) // oxlint-disable-next-line typescript/no-non-null-assertion const sourceEvent = source! if (sourceEvent.type !== 'assistant/chunk') { diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 84f1dd1788..198a660992 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -219,7 +219,7 @@ describe('TokenMeter pricing', () => { const result = service.measure(session) expect(result.baseline.kind).toBe('estimated') expect(result.totalTokens).toBeGreaterThan(result.surfaceTokens) - expect(result.logRevision).toBe(session.events.length) + expect(result.logRevision).toBe(session.snapshotEvents().length) expectSurfaceTotal(result) }) @@ -404,7 +404,7 @@ describe('replay anchors and surface folds', () => { content: [{ type: 'text', text: 'new tail' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const seeded = Session.create(SessionId('surface-seeded'), original.events) + const seeded = Session.create(SessionId('surface-seeded'), original.snapshotEvents()) const before = service.measure(seeded) expect(before.nodes).toHaveLength(2) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) @@ -417,15 +417,15 @@ describe('replay anchors and surface folds', () => { }), { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] }) const after = service.measure(seeded) expect(after.nodes).toHaveLength(2) - expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1) - expect(after.logRevision).toBe(seeded.events.length) + expect(after.nodes[0]!.seq).toBe(seeded.snapshotEvents().length - 1) + expect(after.logRevision).toBe(seeded.snapshotEvents().length) expect(Object.isFrozen(after.nodes)).toBe(true) expect(Object.isFrozen(after.nodes[0])).toBe(true) expect(after.surfaceDeltaTokens).toBeLessThan(0) expectSurfaceTotal(after) expect(before.nodes).toHaveLength(2) // The earlier snapshot still reports the log it measured: seed + boundary. - expect(before.logRevision).toBe(original.events.length + 1) + expect(before.logRevision).toBe(original.snapshotEvents().length + 1) expect(before.surfaceDeltaTokens).toBeGreaterThan(0) }) @@ -437,7 +437,7 @@ describe('replay anchors and surface folds', () => { provenance: 'empty', }) const measurement = meter().measure(session) - const assistant = session.events.find(event => event.type === 'assistant/message')! + const assistant = session.snapshotEvents().find(event => event.type === 'assistant/message')! expect(measurement.nodes).toEqual([{ seq: assistant.seq, tokens: 0, heuristicTokens: 0 }]) expect(measurement.surfaceTokens).toBe(0) expectSurfaceTotal(measurement) @@ -650,7 +650,7 @@ describe('malformed replay and listener lifecycle', () => { source: { kind: 'user' }, }), { surfaceOp: 'append' }) appendHeader(session, header('deepseek-v4-flash')) - const head = session.events[0]!.seq + const head = session.snapshotEvents()[0]!.seq session.append('assistant/message', { turn: 1, step: 1, diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 634efadfb5..72a319fc45 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -28,7 +28,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const seed = (session: Session): void => { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } for (const session of ctx.sessions.list()) seed(session) ctx.on('session/created', (session) => { seed(session) }, { global: true }) diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index a40df90638..0cbb6649d7 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -86,7 +86,7 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'explore the repo' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const header = findEvent(log, 'request/header') expect(planMode.seq).toBeLessThan(header.seq) @@ -114,14 +114,14 @@ describe('plan mode through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) expect(planActive(ctx, agent)).toBe(false) - const first = findEvent(agent.session.events, 'request/header') + const first = findEvent(agent.session.snapshotEvents(), 'request/header') expect(first.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write']) ctx.planMode.set(agent, true) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'now plan' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(planActive(ctx, agent)).toBe(true) const notices = log.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notices).toHaveLength(1) @@ -163,7 +163,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section) expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const nextIdle = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } })) @@ -172,7 +172,7 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(3) expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section) expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) - const log = agent.session.events + const log = agent.session.snapshotEvents() const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' && event.data.turn === 1 && event.data.step === 1) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index f29e15d3fc..cd53724e34 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -142,7 +142,7 @@ function header(session: Session): void { } function noticeTexts(session: Session): string[] { - return session.events + return session.snapshotEvents() .filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin') .map(event => (event.data as { content: { type: string; text?: string }[] }).content.map(block => block.text ?? '').join('')) } @@ -205,19 +205,19 @@ describe('resolveConfig', () => { describe('foldPlanMode', () => { it('folds an empty log to inactive and takes the last plan/mode otherwise', () => { const session = Session.create(SessionId('fold')) - expect(foldPlanMode(session.events)).toBe(false) + expect(foldPlanMode(session.snapshotEvents())).toBe(false) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) session.append('plan/mode', { active: true }) - expect(foldPlanMode(session.events)).toBe(true) + expect(foldPlanMode(session.snapshotEvents())).toBe(true) }) it('folds a prefix when `end` is given', () => { const session = Session.create(SessionId('fold-prefix')) session.append('plan/mode', { active: true }) session.append('plan/mode', { active: false }) - expect(foldPlanMode(session.events, 1)).toBe(true) - expect(foldPlanMode(session.events, 0)).toBe(false) + expect(foldPlanMode(session.snapshotEvents(), 1)).toBe(true) + expect(foldPlanMode(session.snapshotEvents(), 0)).toBe(false) }) }) @@ -281,14 +281,14 @@ describe('ctx.planMode: get/set', () => { const ctx = await setup() const agent = await agentWithSession(ctx, 'agent-idle') expect(ctx.planMode.set(agent, true)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) // Immediately reversible, still without a boundary. expect(ctx.planMode.set(agent, false)).toBe('committed') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) // A later boundary finds nothing pending — no double append. await boundary(ctx, agent, 'step-start') - expect(agent.session.events.filter(event => event.type === 'plan/mode')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(event => event.type === 'plan/mode')).toHaveLength(2) }) it('a between-turns reversal of a mid-turn pending intent cancels without logging', async () => { @@ -299,7 +299,7 @@ describe('ctx.planMode: get/set', () => { closeTurn(agent.session) // Back to the logged state: the pending intent clears, nothing lands. expect(ctx.planMode.set(agent, false)).toBe('cancelled') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(ctx.planMode.get(agent)).toEqual({ active: false }) }) @@ -319,7 +319,7 @@ describe('the boundary flush', () => { const service = ctx.planMode as unknown as { onBoundary(session: Session): void } expect(() => { service.onBoundary(agent.session) }).not.toThrow() - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes from pre-step before the following step/start', async () => { @@ -328,7 +328,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'pre-step') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) @@ -343,7 +343,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) await fiber.dispose() await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) it('flushes at the between-step seam too', async () => { @@ -351,7 +351,7 @@ describe('the boundary flush', () => { const agent = await agentWithSession(ctx) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) @@ -362,7 +362,7 @@ describe('the boundary flush', () => { ctx.planMode.set(agent, true) ctx.planMode.set(agent, false) await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -403,7 +403,7 @@ describe('the boundary flush', () => { agent.session.append('plan/mode', { active: false }) ctx.planMode.set(agent, true) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -430,7 +430,7 @@ describe('the boundary flush', () => { expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) agent.session.append = original await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent).pending).toBeUndefined() }) @@ -674,7 +674,7 @@ describe('/plan', () => { expect(enteringSteer).not.toHaveBeenCalled() await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) - expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(entering.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) const active = await agentWithSession(ctx, 'active-plan-command', { active: true }) openTurn(active.session) @@ -698,10 +698,10 @@ describe('/plan', () => { const agent = await agentWithSession(ctx, 'idle-plan-command') expect((await ctx.commands.execute(agent, '/plan', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode on. Use /plan off to leave.' }) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect((await ctx.commands.execute(agent, '/plan off', [], signal))?.result) .toEqual({ kind: 'success', text: 'Plan mode off.' }) - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) it('steers image attachments with or without text and refuses them on /plan off', async () => { @@ -848,7 +848,7 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Error: exit_plan_mode requires a non-empty markdown plan starting with a # heading' }]) } expect(asked).toHaveLength(0) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades to the manual exit when no user-questions seam is composed', async () => { @@ -857,7 +857,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions channel is available to review the plan; ask the user to switch the session mode instead' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('degrades the same way when the seam has no provider (NO_PROVIDER)', async () => { @@ -865,7 +865,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: no user-questions answerer accepted the request' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => { @@ -885,7 +885,7 @@ describe('exit_plan_mode', () => { text: "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result", }]) expect(ask).not.toHaveBeenCalled() - expect(foldPlanMode(child.session.events)).toBe(true) + expect(foldPlanMode(child.session.snapshotEvents())).toBe(true) }) it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => { @@ -897,10 +897,10 @@ describe('exit_plan_mode', () => { expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }]) // Boundary-applied, not a direct append: the fold stays plan until the // step's end, so the plan policy covers any remaining call of the SAME batch. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true, pending: false }) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(asked).toHaveLength(1) expect(asked[0]?.agent).toBe(agent) expect(asked[0]?.questions[0]?.detail).toBe('# The plan\n\ndo things') @@ -950,7 +950,7 @@ describe('exit_plan_mode', () => { question: 'Approve this plan and leave plan mode?', detail: plan, }) - expect(agent.session.events.find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ + expect(agent.session.snapshotEvents().find(event => event.type === 'tool/code-dispatch')?.data).toMatchObject({ name: EXIT_PLAN_MODE, arguments: { plan }, isError: false, @@ -965,12 +965,12 @@ describe('exit_plan_mode', () => { // Calls of the SAME assistant response were requested under the existing // plan-shaped header. Pending state shapes only the proposed next // assembly; the accepted boundary then commits the matching durable fold. - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) const assembly = await ctx.systemPrompt.assemble({ agent }) expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true) expect(assembly.sections.find(section => section.name === 'plan:policy')?.text).toBe('') await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) const afterExit = await ctx.systemPrompt.assemble({ agent }) expect(afterExit.tools).toEqual(assembly.tools) expect(afterExit.sections.find(section => section.name === 'plan:policy')?.text).toBe('') @@ -981,7 +981,7 @@ describe('exit_plan_mode', () => { header(agent.session) await callExit(ctx, agent) await boundary(ctx, agent, 'step-start') - expect(foldPlanMode(agent.session.events)).toBe(false) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) expect(noticeTexts(agent.session)).toEqual([]) }) @@ -990,7 +990,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: consider the resume path' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('keep planning without feedback returns the generic corrective error', async () => { @@ -1005,7 +1005,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: add tests first' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('requires exactly the single Approve selection', async () => { @@ -1013,7 +1013,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats custom text alongside Approve as feedback, not consent', async () => { @@ -1021,7 +1021,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; their feedback: change the tests' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('treats duplicate review answer items as non-consent', async () => { @@ -1035,7 +1035,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user chose to keep planning; revise the plan and present it again.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a missing answer item reads as keep-planning', async () => { @@ -1067,7 +1067,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('leaves every other review failure its own message', async () => { @@ -1079,7 +1079,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: ask_user_question was aborted before the user answered' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('forwards the execution abort signal to the review question', async () => { @@ -1119,7 +1119,7 @@ describe('exit_plan_mode', () => { const result = await pending expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: the plan-mode service was reloaded while the plan was under review; present the plan again' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('a throwing provider surfaces as the corrective isError and the mode stays plan', async () => { @@ -1128,7 +1128,7 @@ describe('exit_plan_mode', () => { const result = await callExit(ctx, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: review aborted' }]) - expect(foldPlanMode(agent.session.events)).toBe(true) + expect(foldPlanMode(agent.session.snapshotEvents())).toBe(true) }) it('presents the call as a generic card titled by the plan first heading', async () => { @@ -1179,6 +1179,6 @@ describe('HMR disposal', () => { expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)).not.toContain('plan:policy') await boundary(ctx, agent, 'step-start') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'plan/mode')).toBe(false) }) }) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index ff41e160a7..316f4b9408 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -155,7 +155,7 @@ describe('plan projection unit', () => { // A second registry over the same log (the cold-read shape): no service // memory involved, the fold alone answers {active:false, pending:true}. const cold = await harness(true) - for (const event of bench.session.events) { + for (const event of bench.session.snapshotEvents()) { if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') { cold.session.append(event.type, event.data) } diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b7ece833cc..6865a1f59c 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -92,7 +92,7 @@ async function agentOn(ctx: Context, id: string, presetId?: string): Promise - agent.session.events.findLast(event => event.type === 'agent-preset/selected')?.data + agent.session.snapshotEvents().findLast(event => event.type === 'agent-preset/selected')?.data describe('the roster a client reads', () => { it('projects path-free rows, marking the default and carrying published metadata', async () => { diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 32e2c998b7..6f3d5ca654 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -23,7 +23,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended sandbox modes. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - for (const event of session.events) validateEvent(event, fail) + for (const event of session.snapshotEvents()) validateEvent(event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 52172cb09b..a680373163 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -206,7 +206,7 @@ describe('sandbox:policy request context', () => { it('reconstructs resumed policy from the session log and omits diagnostics without an agent', async () => { const active = session('sess-resume', '/projects/current') setSandboxMode(active, 'workspace-write') - const resumed = Session.create(active.id, active.events, active.header) + const resumed = Session.create(active.id, active.snapshotEvents(), active.header) const ctx = await promptMounted({ mode: 'read-only' }) expect(await policyContext(ctx, resumed)).toContain('workspace-write') @@ -231,7 +231,7 @@ describe('the sandbox/mode session kit', () => { it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { const session = Session.create(SessionId('sess-write')) setSandboxMode(session, 'danger-full-access') - const modeEvents = session.events.filter(e => e.type === 'sandbox/mode') + const modeEvents = session.snapshotEvents().filter(e => e.type === 'sandbox/mode') expect(modeEvents).toHaveLength(1) expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' }) }) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 6dc2bb0ace..d0eda965bd 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/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/schedule/schedule/README.md -README.md: 858eca688def615201f3085db023b494505fb065 -README.zh.md: 633b4ff9305b82b93e93e900dc0a35faeebc392e +README.md: 0b2bfaf5d3e22a4a552f35e9bea1a0743bb80bbb +README.zh.md: 603c292b866cbeb0e81dbbe38e39e69310695b85 diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 858eca688d..0b2bfaf5d3 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -94,7 +94,7 @@ The package rests on one separation and three commitments: ### Durable state and replay -A normal Session folds its complete event stream. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. +A normal Session folds its complete event stream. A fork folds only `session.snapshotEvents(session.header.seedLength ?? 0)`, so a child never inherits its parent's reminders. Every create record carries a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record also stores `afterSeconds`, an `at` record stores no copy of its submitted offset or local fields, and an `every` record stores `everySeconds` with `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id; an `every` dispatch adds `acceptedAt`, and replay advances directly to the first anchor-aligned target after that decision time. ### Time validation diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 633b4ff930..603c292b86 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -94,7 +94,7 @@ Time-context 不是 Schedule 的依赖。官方 Web overlay 挂载 `@deepseek-ai ### 持久状态与回放 -普通会话折叠完整事件流。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 +普通会话折叠完整事件流。fork 只折叠 `session.snapshotEvents(session.header.seedLength ?? 0)`,因此子会话永远不会继承父会话的提醒。每条 create 记录都携带稳定的会话本地 `ScheduleId`、已 trim 的提示词与四位年份 RFC 3339 UTC `scheduledAt`;`after` 记录还存储 `afterSeconds`,`at` 记录不保留所提交的偏移量或本地字段,`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id;`every` dispatch 会附加 `acceptedAt`,回放直接推进到该决策时点之后的第一个锚点对齐目标。 ### 时间校验 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 8a619e2e73..d94bb6445d 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -48,7 +48,7 @@ export function apply(ctx: Context): void { const cleanup: OwnerCleanup = agent.ctx.effect(() => { const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { runtime.requestDrive() }) const stopStatus = agent.ctx.on('agent/status', ({ status }) => { - if (status === 'idle' && agent.session.events.some(event => event.type === 'schedule/change')) { + if (status === 'idle' && agent.session.snapshotEvents().some(event => event.type === 'schedule/change')) { runtime.requestDrive() } }) diff --git a/packages/schedule/schedule/src/invariant.ts b/packages/schedule/schedule/src/invariant.ts index 59a9ab6c21..5af5245ade 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -30,16 +30,16 @@ function validate(events: readonly SessionEvent[], seedLength: number, fail: Inv /** Install replay and pre-append validation for the owned event stream. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) } ctx.on('session/created', (session) => { - validate(session.events, session.header.seedLength ?? 0, fail) + validate(session.snapshotEvents(), session.header.seedLength ?? 0, fail) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'schedule/change') return - validate([...session.events, event], session.header.seedLength ?? 0, fail) + validate([...session.snapshotEvents(), event], session.header.seedLength ?? 0, fail) }, { global: true }) }, { inject: ['sessions'] }) /* jscpd:ignore-end */ diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index e6f4fce72c..b7f45848c5 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -206,7 +206,7 @@ export class ScheduleRuntime { private readFolded(): FoldedSchedules | undefined { try { return foldScheduleEvents( - this.agent.session.events, + this.agent.session.snapshotEvents(), this.agent.session.header.seedLength ?? 0, ) } catch (error: unknown) { diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index a7f4d3e8a7..616d8650fb 100644 --- a/packages/schedule/schedule/src/tools.ts +++ b/packages/schedule/schedule/src/tools.ts @@ -221,7 +221,7 @@ function inputError(error: ScheduleInputError): ScheduleToolError { /** Fold only after a successful preflight, mapping corruption to a stable value. */ function foldForTool(agent: Agent): ReturnType | ScheduleToolError { try { - return foldScheduleEvents(agent.session.events, agent.session.header.seedLength ?? 0) + return foldScheduleEvents(agent.session.snapshotEvents(), agent.session.header.seedLength ?? 0) } catch (error: unknown) { return error instanceof ScheduleLogError ? corruptLogError() : internalError() } diff --git a/packages/schedule/schedule/tests/invariant.spec.ts b/packages/schedule/schedule/tests/invariant.spec.ts index 88e7465260..0896a8b6f9 100644 --- a/packages/schedule/schedule/tests/invariant.spec.ts +++ b/packages/schedule/schedule/tests/invariant.spec.ts @@ -53,17 +53,17 @@ describe('Schedule package invariant', () => { const session = ctx.sessions.create(SessionId('schedule-invariant')) session.append('turn/start', { turn: 1 }) session.append('schedule/change', create('schedule-1')) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) expect(() => session.append('schedule/change', { version: 1, operation: 'delete', id: ScheduleId('missing'), })).toThrow(InvariantError) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) session.append('schedule/change', { version: 1, operation: 'dispatch', id: ScheduleId('schedule-1') }) - expect(session.events).toHaveLength(3) + expect(session.snapshotEvents()).toHaveLength(3) await ctx.fiber.dispose() }) @@ -82,7 +82,7 @@ describe('Schedule package invariant', () => { id: ScheduleId('schedule-every'), acceptedAt: '2026-08-05T12:17:34.000Z', }) - expect(session.events).toHaveLength(2) + expect(session.snapshotEvents()).toHaveLength(2) await ctx.fiber.dispose() }) @@ -117,7 +117,7 @@ describe('Schedule package invariant', () => { }) const fiber = await ctx.plugin(scheduleInvariant) child.append('schedule/change', create('child')) - expect(child.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(child.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await fiber.dispose() await ctx.fiber.dispose() }) diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index d18e35aac9..879b16d0ad 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -129,7 +129,7 @@ describe('Schedule production JSONL restart', () => { await replayed.sessions.flush(replayHandle.agent.session) expect(replayAdapter.requests).toEqual([]) - expect(replayHandle.agent.session.events.filter(event => + expect(replayHandle.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toHaveLength(1) const replayedStored = await replayed.sessionPersistence.inspect(sessionId) expect(replayedStored.events.filter(event => diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 609c68a6cd..86b8246c32 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -173,7 +173,7 @@ describe('Schedule timer and admission runtime', () => { await settle() expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.find(event => + expect(test.agent.session.snapshotEvents().find(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toBeDefined() await runtime.dispose() }) @@ -223,7 +223,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.followed).toEqual([]) expect(test.controls.whenIdleCount).toBe(1) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) runtime.requestDrive() await settle() @@ -295,13 +295,13 @@ describe('Schedule timer and admission runtime', () => { ].join('\n'), }]) expect(test.followed[0]?.source).toEqual({ kind: 'plugin', plugin: 'schedule' }) - const dispatches = test.agent.session.events.filter(event => + const dispatches = test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch') expect(dispatches.map(event => event.data)).toEqual([ { version: 1, operation: 'dispatch', id: 'schedule-fast', acceptedAt: '2026-08-05T12:00:00.000Z' }, { version: 1, operation: 'dispatch', id: 'schedule-slow', acceptedAt: '2026-08-05T12:00:00.000Z' }, ]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-fast', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-slow', scheduledAt: '2026-08-05T12:09:00.000Z' }), ]) @@ -370,7 +370,7 @@ describe('Schedule timer and admission runtime', () => { expect(test.controls.releaseCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'delete' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'delete' }) runtime.requestDrive() await settle() expect(test.followed).toEqual([]) @@ -406,9 +406,9 @@ describe('Schedule timer and admission runtime', () => { appendAfter(unreadable, 'schedule-1', 1, Date.now() - 1_000) unreadable.controls.onReserve = () => { unreadable.controls.onReserve = undefined - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw new Error('became unreadable') }, + value: () => { throw new Error('became unreadable') }, }) } const unreadableRuntime = runtimeFor(unreadable) @@ -429,7 +429,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) await runtime.dispose() @@ -460,7 +460,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) runtime.requestDrive() await settle() @@ -487,7 +487,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(test.followed).toHaveLength(1) expect(test.controls.releaseCount).toBe(1) - expect(test.agent.session.events.filter(event => ( + expect(test.agent.session.snapshotEvents().filter(event => ( event.type === 'schedule/change' && event.data.operation === 'dispatch' )).map(event => event.data)).toEqual([{ version: 1, @@ -495,7 +495,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { id: 'schedule-first', acceptedAt: '2026-08-05T12:00:00.000Z', }]) - expect(foldScheduleEvents(test.agent.session.events).active).toEqual([ + expect(foldScheduleEvents(test.agent.session.snapshotEvents()).active).toEqual([ expect.objectContaining({ id: 'schedule-first', scheduledAt: '2026-08-05T12:05:00.000Z' }), expect.objectContaining({ id: 'schedule-second', scheduledAt: '2026-08-05T11:55:00.000Z' }), ]) @@ -545,7 +545,7 @@ describe('Schedule runtime failure and teardown boundaries', () => { await settle() expect(test.controls.flushCount).toBe(1) expect(test.followed).toEqual([]) - expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(test.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) await runtime.dispose() const departed = await harness() @@ -607,15 +607,15 @@ describe('Schedule runtime failure and teardown boundaries', () => { } await settle() expect(test.followed).toEqual([]) - expect(test.agent.session.events.filter(event => + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change' && event.data.operation === 'dispatch')).toEqual([]) }) it('faults on corrupt or unreadable durable state after preflight', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -626,9 +626,9 @@ describe('Schedule runtime failure and teardown boundaries', () => { expect(corrupt.followed).toEqual([]) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) const unreadableRuntime = runtimeFor(unreadable) unreadableRuntime.start() diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index b8ef0be6f8..4ac5d91717 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -159,7 +159,7 @@ describe('Schedule tool protocol', () => { expect(value(await execute(test, 'schedule_create', { prompt: 'x', every_seconds: 299 }))) .toEqual({ code: 'frequency_too_high', message: 'every_seconds must be at least 300.' }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('creates, lists, marks overdue, deletes, and never reuses an id', async () => { @@ -232,7 +232,7 @@ describe('Schedule tool protocol', () => { expect.objectContaining({ id: 'schedule-1', kind: 'at' }), expect.objectContaining({ id: 'schedule-2', kind: 'at' }), ]) - const changes = test.agent.session.events + const changes = test.agent.session.snapshotEvents() .filter(event => event.type === 'schedule/change' && event.data.operation === 'create') expect(changes.map((change) => { if (change.type !== 'schedule/change' || change.data.operation !== 'create') { @@ -300,7 +300,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be strictly in the future.', }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('returns a range error only after the create preflight', async () => { @@ -312,7 +312,7 @@ describe('Schedule tool protocol', () => { message: 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const internal = await harness() const now = vi.spyOn(Date, 'now').mockImplementationOnce(() => { throw new Error('clock unavailable') }) @@ -350,9 +350,9 @@ describe('Schedule tool protocol', () => { describe('Schedule persistence failure boundaries', () => { it('does not fold an unconfirmed corrupt live suffix before preflight succeeds', async () => { const test = await harness() - Object.defineProperty(test.agent.session, 'events', { + Object.defineProperty(test.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), @@ -438,7 +438,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(0) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a create cancelled during its first preflight', async () => { @@ -462,7 +462,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(1) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) }) it('does not persist a delete cancelled during its first preflight', async () => { @@ -485,7 +485,7 @@ describe('Schedule persistence failure boundaries', () => { error: { info: { name: 'AbortError', code: 'ABORTED' } }, }) expect(test.flushes.count).toBe(3) - expect(test.agent.session.events.filter(event => event.type === 'schedule/change')) + expect(test.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')) .toHaveLength(1) expect(value(await execute(test, 'schedule_list', {}))) .toEqual([expect.objectContaining({ id: 'schedule-1' })]) @@ -496,21 +496,21 @@ describe('Schedule persistence failure boundaries', () => { createTest.flushes.outcomes.push('reject') expect(value(await execute(createTest, 'schedule_create', { prompt: 'later', after_seconds: 1 }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'create' }) - expect(createTest.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([]) + expect(createTest.agent.session.snapshotEvents().filter(event => event.type === 'schedule/change')).toEqual([]) const deleteTest = await harness() await execute(deleteTest, 'schedule_create', { prompt: 'keep', after_seconds: 1 }) deleteTest.flushes.outcomes.push('reject') expect(value(await execute(deleteTest, 'schedule_delete', { id: 'schedule-1' }))) .toMatchObject({ code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1' }) - expect(deleteTest.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'create' }) + expect(deleteTest.agent.session.snapshotEvents().at(-1)?.data).toMatchObject({ operation: 'create' }) }) it('maps corrupt and unreadable folds for create, list, and delete', async () => { const corrupt = await harness() - Object.defineProperty(corrupt.agent.session, 'events', { + Object.defineProperty(corrupt.agent.session, 'snapshotEvents', { configurable: true, - value: [{ + value: () => [{ type: 'schedule/change', seq: 0, time: Date.now(), data: { version: 9, operation: 'delete', id: 'schedule-1' }, }], @@ -521,9 +521,9 @@ describe('Schedule persistence failure boundaries', () => { .toMatchObject({ code: 'corrupt_schedule_log' }) const unreadable = await harness() - Object.defineProperty(unreadable.agent.session, 'events', { + Object.defineProperty(unreadable.agent.session, 'snapshotEvents', { configurable: true, - get() { throw 'unreadable log' }, + value: () => { throw 'unreadable log' }, }) expect(value(await execute(unreadable, 'schedule_list', {}))) .toEqual({ code: 'internal_error', message: 'The schedule operation failed.' }) diff --git a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts index a1e58b11ae..1d61e0ef3a 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts @@ -65,7 +65,7 @@ describe('session-log-download real Loader composition', () => { }) const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' }) - expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['command/run', 'command/done']) expect(session.deriveMessages()).toEqual([]) }) }) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1c4569df4c..f7e4ce043d 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -854,7 +854,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession(session.header, session.events) + return observeSession(session.header, session.snapshotEvents()) } function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 69fbc8a1a9..487b6a9702 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -239,7 +239,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { - return { header: session.header, events: session.events } + return { header: session.header, events: session.snapshotEvents() } } function orderedResults( @@ -292,7 +292,7 @@ async function inspectPersisted( function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), - events: session.events.map(event => structuredClone(event)), + events: session.snapshotEvents().map(event => structuredClone(event)), } } diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 8810e1972f..4d56007d63 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -151,7 +151,7 @@ export class SessionObservationReader { session: Session, projectionMode: NonNullable, ): SessionObservation { - const events = Object.freeze([...session.events]) + const events = session.snapshotEvents() const projections = projectionMode === 'none' ? undefined : this.ctx.get('sessionProjections')?.snapshot(session) diff --git a/packages/session-query/session-query/tests/observation.spec.ts b/packages/session-query/session-query/tests/observation.spec.ts index 7dc856ee29..7151714b67 100644 --- a/packages/session-query/session-query/tests/observation.spec.ts +++ b/packages/session-query/session-query/tests/observation.spec.ts @@ -18,7 +18,7 @@ function preparedSource( const preparedSession = Session.create(meta.id, [], meta) return { source: 'prepared', - inspection: { meta: preparedSession.header, events: preparedSession.events }, + inspection: { meta: preparedSession.header, events: preparedSession.snapshotEvents() }, revision: SessionPersistenceRevision(`fixture:${meta.id}`), preparedSession, [Symbol.dispose]: dispose, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 369fa3b3ae..77161b935f 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1002,7 +1002,8 @@ describe('session-query exact reads', () => { }).toThrow() Object.assign(snapshot.session, { cwd: '/mutated' }) - expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1) + const logged = session.eventAt(4) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) expect(session.header.cwd).toBe('/work') }) @@ -1038,7 +1039,8 @@ describe('session-query exact reads', () => { (result.events[0]!.data as { content: unknown[] }).content = [] }).toThrow() expect(session.header.createdAt).not.toBe(-1) - expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1) + const logged = session.eventAt(1) + expect(logged?.type === 'user/message' && logged.data.content).toHaveLength(1) await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 })) .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index 08d07d500b..697adc5951 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -66,7 +66,7 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - events: agent.session.events, + events: agent.session.snapshotEvents(), boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 9db2a45da7..eaa4ce7cfe 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -8,6 +8,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type Session, + type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -1213,7 +1214,7 @@ describe('workspace authority and lineage redaction', () => { const appendLegacy = mounted.caller.append.bind(mounted.caller) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent const secret = appendLegacy( 'context/message', { @@ -2008,7 +2009,7 @@ describe('trace and exact read rendering', () => { expect(text(result)).toContain('Replacement chain: 1') expect(text(result)).toContain('Events cited directly as sources: none') expect(text(result)).toContain('Direct derived events: 1') - expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString()) + expect(text(result)).toContain(new Date(session.snapshotEvents()[0]?.time ?? 0).toISOString()) }) it('renders unabridged fenced target JSON and readable semantic or log-only neighbor summaries', async () => { @@ -2040,7 +2041,7 @@ describe('trace and exact read rendering', () => { const appendLegacy = session.append.bind(session) as unknown as ( type: string, data: unknown, - ) => Session['events'][number] + ) => SessionEvent appendLegacy( 'context/message', { content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } }, diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index b66c21706c..a472e62f63 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-deepseek-llm-api-extensions' -import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type Session } from '@deepseek-ai/dsh-session' import type { DeepSeekSessionLogExtension } from './types.ts' export type * from './types.ts' @@ -44,10 +44,13 @@ const acceptanceFolds = new WeakMap() export function acceptedThrough(session: Session): number { const previous = acceptanceFolds.get(session) let throughSeq = previous?.throughSeq ?? -1 - const events = session.events + const length = session.seq const start = previous?.scannedEvents ?? 0 - for (let index = start; index < events.length; index++) { - const event = events[index] as SessionEvent + for (let index = start; index < length; index++) { + const event = session.eventAt(index) + if (event === undefined) { + throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`) + } if (event.type !== 'session-log-deepseek/delivery-accepted') continue if (typeof event.data.sessionId !== 'string' || event.data.sessionId.length === 0 || !Number.isSafeInteger(event.data.throughSeq) || event.data.throughSeq < 0 @@ -57,7 +60,7 @@ export function acceptedThrough(session: Session): number { if (event.data.sessionId !== session.id) continue throughSeq = Math.max(throughSeq, event.data.throughSeq) } - acceptanceFolds.set(session, { scannedEvents: events.length, throughSeq }) + acceptanceFolds.set(session, { scannedEvents: length, throughSeq }) return throughSeq } @@ -76,10 +79,9 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) - const snapshot = session.events - const throughSeq = snapshot.length - 1 + const throughSeq = session.seq - 1 if (throughSeq < 0) return undefined - const suffix = snapshot.slice(afterSeq + 1) + const suffix = session.snapshotEvents(afterSeq + 1) const value: DeepSeekSessionLogExtension = { version: 1, session: session.header, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index e739126ab6..b7c2c848f9 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -28,7 +28,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session /** Validate acceptance watermarks already present in one Session. */ function validateSession(session: Session, fail: InvariantFailure): void { - for (const event of session.events) { + for (const event of session.snapshotEvents()) { if (event.type === 'session-log-deepseek/delivery-accepted') validateDeliveryAccepted(session, event, fail) } } diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index d473cc7de9..560851a4d8 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -76,7 +76,7 @@ describe('incremental DeepSeek session-log upload', () => { first.session.append('turn/start', { turn: 1 }) const prepared = await first.ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: first.session.id }) await prepared.accept() - const seed = first.session.events + const seed = first.session.snapshotEvents() const resumed = await harness('parent', seed) expect(SessionLogDeepSeek.acceptedThrough(resumed.session)).toBe(0) @@ -110,13 +110,14 @@ describe('incremental DeepSeek session-log upload', () => { { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2, data: { sessionId: id, throughSeq: 0 } }, ] let reads = 0 - const observed = new Proxy(events, { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) reads++ - return Reflect.get(target, property, receiver) as unknown + const session = { + id, + get seq() { return events.length }, + eventAt(seq: number) { + reads++ + return events[seq] }, - }) - const session = { id, get events() { return observed } } as unknown as Session + } as unknown as Session expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0) expect(reads).toBe(2) @@ -155,7 +156,7 @@ describe('incremental DeepSeek session-log upload', () => { const { ctx, session } = await harness('direct-events') session.append('turn/start', { turn: 1 }) const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: {}, signal: SIGNAL, sessionId: session.id }) - expect(prepared.fields.dsh_session_log?.events).toEqual(session.events) + expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents()) }) it('fails closed on a malformed persisted acceptance watermark', async () => { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index e244d3af51..e08e2c628c 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -592,13 +592,13 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { const loaded = await ctx.sessionPersistence.load(child.id) // The constructor seed reaches disk verbatim, then the child's end-seed. - expect(loaded.events.slice(0, source.events.length)).toEqual(source.events) - expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.events.length }) + expect(loaded.events.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents()) + expect(loaded.events.at(-1)).toMatchObject({ type: 'session/end-seed', seq: source.snapshotEvents().length }) expect(loaded.meta).toMatchObject({ id: SessionId('persist-child'), cwd: '/workspace', parentSession: SessionId('persist-parent'), - seedLength: source.events.length, + seedLength: source.snapshotEvents().length, }) }) @@ -1647,13 +1647,13 @@ describe('JsonlSessionPersistence: edge cases', () => { it('Session.append rejects a non-serializable event at the source (never enters the log)', () => { const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a BigInt-bearing - // event before it enters session.events, so the durable log can never diverge from the live + // event before it enters session.snapshotEvents(), so the durable log can never diverge from the live // log. The error therefore surfaces synchronously at append, not later during backend flush. expect(() => { session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) // The bad event was rejected, so the log stayed empty. - expect(session.events.length).toBe(0) + expect(session.snapshotEvents().length).toBe(0) }) }) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 0293c01555..75e8bd9b72 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -762,7 +762,7 @@ export class PersistenceCoordinator { this.preparations.release( reservation, reservation.state.owner === undefined - && reservation.source.session.events.length === reservation.source.sessionLength, + && reservation.source.session.seq === reservation.source.sessionLength, ) }, }) @@ -996,7 +996,7 @@ export class PersistenceCoordinator { inspection, session, revision, - sessionLength: session.events.length, + sessionLength: session.seq, tornMarker, closers, } @@ -1053,7 +1053,7 @@ export class PersistenceCoordinator { /** Return one durable immutable view of an already-live Session. */ private async loadLiveSnapshot(session: Session): Promise { - const events = session.events + const events = session.snapshotEvents() await this.flush(session) const state = this.states.get(session.id) /* v8 ignore next -- successful flush always publishes this live session's durable state */ @@ -1067,7 +1067,7 @@ export class PersistenceCoordinator { /** Borrow one immutable view from an already-live Session. */ private inspectLive(session: Session): SessionInspection { - return Object.freeze({ meta: session.header, events: session.events }) + return Object.freeze({ meta: session.header, events: session.snapshotEvents() }) } /** Await one retiring lifecycle with caller cancellation. */ @@ -1248,7 +1248,7 @@ export class PersistenceCoordinator { return restored } // Session owns this stable deep-frozen snapshot; backends only serialize it. - const seed = session.events + const seed = session.snapshotEvents() const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), @@ -1270,7 +1270,7 @@ export class PersistenceCoordinator { || session.firstLiveSeq !== state.cursor) { throw new Error(`session "${session.id}" preparation no longer matches its persistence state`) } - const suffix = session.events.slice(state.cursor).map(event => structuredClone(event)) + const suffix = session.snapshotEvents(state.cursor).map(event => structuredClone(event)) this.preparations.attach(reservation) state.owner = session const live: LiveSessionState = { diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index a596ed0b11..474ee4ca34 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -304,7 +304,7 @@ describe('PersistenceCoordinator seed ownership', () => { try { const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) - const seed = session.events + const seed = session.snapshotEvents() await ctx.sessions.flush(session) expect(backend.lastAppendedBatch).toBe(seed) @@ -764,7 +764,7 @@ describe('PersistenceCoordinator session preparations', () => { first = await coordinator.prepare(id) expect(backend.loadAttempts).toBe(1) - expect(first.session.events[0]).toBe(inspected.events[0]) + expect(first.session.snapshotEvents()[0]).toBe(inspected.events[0]) first[Symbol.dispose]() second = await coordinator.prepare(id) @@ -826,8 +826,8 @@ describe('PersistenceCoordinator session preparations', () => { ) preparation = await coordinator.prepare(id) - expect(preparation.session.events).toHaveLength(9) - expect(preparation.session.events[0]).not.toBe(inspected.events[0]) + expect(preparation.session.snapshotEvents()).toHaveLength(9) + expect(preparation.session.snapshotEvents()[0]).not.toBe(inspected.events[0]) expect(backend.loadAttempts).toBe(2) } finally { preparation?.[Symbol.dispose]() @@ -986,7 +986,7 @@ describe('PersistenceCoordinator session preparations', () => { session.append('turn/start', { turn: 1 }) const inspected = await coordinator.inspect(session.id) - expect(inspected.events).toBe(session.events) + expect(inspected.events).toBe(session.snapshotEvents()) expect(inspected.events.map(event => event.type)).toEqual(['turn/start']) await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/) } finally { @@ -1068,7 +1068,7 @@ describe('PersistenceCoordinator session preparations', () => { try { preparation = await coordinator.prepare(id) - expect(preparation.session.events.map(event => event.type)).toEqual([ + expect(preparation.session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'turn/end', 'turn/start', @@ -2102,7 +2102,7 @@ describe('SessionPersistence service registration', () => { const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } })) .toThrow(/unsupported legacy request\/header-delta format/) - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) @@ -2115,7 +2115,7 @@ describe('SessionPersistence service registration', () => { expect(() => appendLegacy('request/header', legacyFallbackHeader().data)) .toThrow('unsupported legacy request/header reason "fallback"') - expect(session.events).toHaveLength(0) + expect(session.snapshotEvents()).toHaveLength(0) await fiber.dispose() }) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 0a11f037b6..896c9fa632 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -584,10 +584,10 @@ export class SessionProjectionRegistry extends Service { private cellFor(registration: Registration, session: Session): UnitCell { let cell = registration.cells.get(session) if (cell === undefined) { - cell = this.buildCell(registration.def, session.header, session.events) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents()) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, session.seq - 1) + this.advanceCell(registration.def, cell, session, session.seq - 1) } return cell } @@ -596,12 +596,12 @@ export class SessionProjectionRegistry extends Service { private advanceCell( def: ErasedDefinition, cell: UnitCell, - events: readonly SessionEvent[], + session: Session, throughSeq: number, ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { - const event = events[seq] + const event = session.eventAt(seq) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) } @@ -619,10 +619,10 @@ export class SessionProjectionRegistry extends Service { if (cell === undefined) { // Late build mid-stream: fold history before this event (seq = log // index, so the prefix slice is exact), then take the normal gate. - cell = this.buildCell(registration.def, session.header, session.events.slice(0, event.seq)) + cell = this.buildCell(registration.def, session.header, session.snapshotEvents(0, event.seq)) registration.cells.set(session, cell) } else { - this.advanceCell(registration.def, cell, session.events, event.seq - 1) + this.advanceCell(registration.def, cell, session, event.seq - 1) } const next = registration.def.apply(cell.state, event) const changed = !Object.is(next, cell.state) diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index f5601caa0f..4c5dc734e0 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/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-telemetry-otel/README.md -README.md: 8aa215f5fd2e1344911e3f069f038a4f89f6fa3f -README.zh.md: 1bb3aa8f3b3e4b2f50e66e1833a8f4ab00112a8b +README.md: f270878962bdc365915e76a67ffc2490db14d33e +README.zh.md: d8fcef0610b22abddb3f051cb6056d89f3fc65b5 diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 8aa215f5fd..f270878962 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -93,7 +93,7 @@ The backend is a thin adapter over the OTel JS SDK: it owns capture mode, resour ### Capture wiring -`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only for the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. +`FULL` composes the coordinator in `live` mode and lets direct service calls through; `FEEDBACK_ONLY` composes it in `on-demand` mode, gives the coordinator a private backend capability, and triggers `captureSession(session, event.seq)` only when `session.eventAt(event.seq) === event` confirms the exact canonical feedback record; `DISABLED` registers nothing but a warning on `feedback/record`. The backend deliberately implements no `flush()`: the batch processor owns ordinary flushing, and forwarding the hint to `forceFlush()` would create the sole source of concurrent flushes whose interaction with shutdown's drain is undocumented. ### Field mapping diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 1bb3aa8f3b..d8fcef0610 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -93,7 +93,7 @@ kind: "package-reference" ### 捕获接线 -`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且只对权威日志中精确的反馈记录触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 +`FULL` 以 `live` 模式组装协调器,并放行直接服务调用;`FEEDBACK_ONLY` 以 `on-demand` 模式组装协调器,给协调器一个私有后端能力,并且仅在 `session.eventAt(event.seq) === event` 确认精确的权威反馈记录时触发 `captureSession(session, event.seq)`;`DISABLED` 除了在 `feedback/record` 上发出警告外不注册任何内容。后端刻意不实现 `flush()`:常规 flush 由批处理器负责,把提示转发给 `forceFlush()` 会成为并发 flush 的唯一来源,而它与关闭排空的交互没有文档。 ### 字段映射 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 169f4dc9de..c88d442c87 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -244,7 +244,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { ctx.on('session/event', (session, event) => { if (event.type !== 'feedback/record') return // Consent is the committed record, not an independently emitted bus value. - if (session.events[event.seq] !== event) { + if (session.eventAt(event.seq) !== event) { ctx.logger.warn(NON_CANONICAL_FEEDBACK_WARNING) return } diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index afb96c8f60..822203abb8 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -150,7 +150,7 @@ describe('OpenTelemetrySessionBackend wire', () => { const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) expect(start).toBeDefined() expect(start?.record.severityNumber).toBe(9) - expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.snapshotEvents()[0]!.time) * 1_000_000n) expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) @@ -325,7 +325,7 @@ describe('OpenTelemetrySessionBackend wire', () => { }) ctx.emit('session/event', session, { type: 'feedback/record', - seq: session.events.length, + seq: session.snapshotEvents().length, time: Date.now(), data: { text: 'not committed' }, }) diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index ff0417f168..8725888d7b 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -137,9 +137,14 @@ export class SessionTelemetryCoordinator { */ captureSession(session: Session, throughSeq?: number): void { const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 + const length = session.seq // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. - for (const event of session.events) { + for (let seq = 0; seq < length; seq++) { + const event = session.eventAt(seq) + if (event === undefined) { + throw new Error(`session telemetry capture cannot read seq ${String(seq)} below captured length ${String(length)}`) + } if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { if (event.seq <= cursor) this.track(session, event) diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index 219c27ebcf..b563d9c469 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -72,7 +72,7 @@ describe('session-telemetry/record waterfall', () => { session.append('user/message', createUserMessage({ content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } + const logged = session.snapshotEvents()[0]!.data as { content: { text: string }[] } expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) }) @@ -124,6 +124,6 @@ describe('session-telemetry/record waterfall', () => { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) expect(backend.records).toHaveLength(0) - expect(session.events).toHaveLength(1) + expect(session.snapshotEvents()).toHaveLength(1) }) }) diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index a1d80a1289..403451a49a 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -96,12 +96,12 @@ describe('SessionTelemetryCoordinator capture', () => { const start = backend.ledger()[0]! const message = backend.ledger()[1]! expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) - expect(start.time).toBe(session.events[0]!.time) + expect(start.time).toBe(session.snapshotEvents()[0]!.time) expect(start.severity).toBe('info') expect(message.attributes['event.seq']).toBe(1) // Deep-copy isolation: mutating the handed-off body never reaches the log. ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' - const logged = session.events[1] as SessionEvent<'user/message'> + const logged = session.snapshotEvents()[1] as SessionEvent<'user/message'> expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) }) @@ -183,7 +183,7 @@ describe('SessionTelemetryCoordinator on-demand capture', () => { const { ctx, backend, coordinator } = await setup(new FakeBackend(), 'on-demand') const session = liveSession(ctx, 'on-demand-prefix') appendTurn(session) - const firstBoundary = session.events[1]!.seq + const firstBoundary = session.snapshotEvents()[1]!.seq session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(backend.records).toEqual([]) @@ -288,7 +288,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) ctx.sessions.announce(child) @@ -307,7 +307,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) - const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.snapshotEvents()], meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], @@ -335,7 +335,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - seed: [...parent.events], + seed: [...parent.snapshotEvents()], meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, }) await ctx.plugin({ diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts index 8c358bfc35..a16b415b72 100644 --- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts @@ -53,7 +53,7 @@ describe('all-messages LLM title provider', () => { ctx.llm.registerAdapter(['current-route'], adapter) await ctx.plugin(providerPlugin, LLM_CONFIG) const session = ctx.sessions.create(SessionId('all-plugin'), { - seed: seeded.events, + seed: seeded.snapshotEvents(), meta: { parentSession: seeded.id, seedLength: seeded.seq }, }) session.append('turn/start', { turn: 2 }) diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index f635c921e8..240ddefa2b 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -126,7 +126,7 @@ describe('generateSessionTitleWithLlm', () => { const providerRequest = request(ctx) let requestWasLoggedAtDispatch = false const adapter = new RecordingAdapter(SCRIPT, () => { - requestWasLoggedAtDispatch = providerRequest.session.events + requestWasLoggedAtDispatch = providerRequest.session.snapshotEvents() .some(event => event.type === 'session/title-llm-request') }) ctx.llm.registerAdapter(['current-route'], adapter) @@ -162,7 +162,7 @@ describe('generateSessionTitleWithLlm', () => { const prompt = options.messages[0]?.content[0] expect(prompt?.type === 'text' && prompt.text).toContain('first prompt') expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题') - expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data) + expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data) .toEqual({ titleProvider: TITLE_PROVIDER, messageSeqs: providerRequest.messages.map(message => message.seq), @@ -193,7 +193,7 @@ describe('generateSessionTitleWithLlm', () => { await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER)) .rejects.toThrow(/input.*bytes.*maxInputBytes/i) expect(adapter.requests).toEqual([]) - expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false) + expect(oversized.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false) const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 }) const within = request(ctx) @@ -261,7 +261,7 @@ describe('generateSessionTitleWithLlm', () => { providerRequest.messages, TITLE_PROVIDER, )).rejects.toMatchObject({ message, code }) - expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true) + expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(true) }) it.each([ diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 5b6df7e414..e220cfdf3a 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -381,7 +381,7 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { - return foldSessionTitle(session.events) + return foldSessionTitle(session.snapshotEvents()) } /** @@ -591,7 +591,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) - const messages = collectSessionTitleMessages(session.events, work.throughSeq) + const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq) const result = await work.registration.provider.generate({ session, messages, diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 53fc323d38..f96da740ff 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -65,8 +65,8 @@ describe('SessionTitleService Provider lifecycle', () => { const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) - expect(child.events.find(event => event.type === 'session/title')) - .toEqual(parent.events.find(event => event.type === 'session/title')) + expect(child.snapshotEvents().find(event => event.type === 'session/title')) + .toEqual(parent.snapshotEvents().find(event => event.type === 'session/title')) const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ title: 'Should not run', @@ -376,7 +376,7 @@ describe('SessionTitleService Provider lifecycle', () => { }))) await settle() - expect(session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1) expect(requests).toHaveLength(2) expect(requests[1]).toMatchObject({ messages: [ diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index 7701d953b7..9654c18edc 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -46,14 +46,14 @@ describe('SessionTitleService.rename', () => { messageSeqs: [], source: { kind: 'user' }, }) - const event = session.events.findLast(item => item.type === 'session/title') + const event = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(event?.data).toEqual({ title: 'Hand picked name', messageSeqs: [], source: { kind: 'user' }, }) // foldSessionTitle round-trips the third source kind. - expect(foldSessionTitle(session.events)?.source).toEqual({ kind: 'user' }) + expect(foldSessionTitle(session.snapshotEvents())?.source).toEqual({ kind: 'user' }) }) it('rejects titles that normalize to empty and dead sessions', async () => { @@ -164,7 +164,7 @@ describe('SessionTitleService.rename', () => { await settle() // The released provider result must not append over the user title, and // the swallowed abort must not surface as an unhandled rejection. - const latest = session.events.findLast(item => item.type === 'session/title') + const latest = session.snapshotEvents().findLast(item => item.type === 'session/title') expect(latest?.data).toMatchObject({ title: 'User wins', source: { kind: 'user' } }) }) diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 6ac579bd35..ce3e4997bb 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -171,7 +171,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) const source = appendPrompt(seed, 'Create exactly one fallback title') seed.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.events }) + const session = ctx.sessions.create(SessionId('fallback-concurrency'), { seed: seed.snapshotEvents() }) const results = await Promise.all([ ctx.sessionTitle.refresh(session), @@ -179,8 +179,8 @@ describe('SessionTitleService configuration and refresh boundaries', () => { ]) expect(results[0]).toEqual(results[1]) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'turn/start', 'user/message', 'turn/end', @@ -204,7 +204,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { }) await expect(refresh).resolves.toMatchObject({ title: 'Already accepted' }) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('lets the newest overlapping explicit refresh win', async () => { @@ -266,7 +266,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await fiber.dispose() await settle() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) const inactiveError = await lifecycle.inactiveRefresh expect(inactiveError).toBeInstanceOf(Error) if (!(inactiveError instanceof Error)) throw new Error('expected inactive refresh to reject') @@ -285,7 +285,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => { await Promise.resolve() await fiber.dispose() - expect(session.events.some(event => event.type === 'session/title')).toBe(false) + expect(session.snapshotEvents().some(event => event.type === 'session/title')).toBe(false) expect(warn).not.toHaveBeenCalled() }) diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index b96eb6001b..0254ff5826 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -53,7 +53,7 @@ describe('SessionTitleService', () => { await settleTitles() - const titleEvent = session.events.findLast(event => event.type === 'session/title') + const titleEvent = session.snapshotEvents().findLast(event => event.type === 'session/title') expect(titleEvent).toMatchObject({ type: 'session/title', seq: 2, @@ -140,7 +140,7 @@ describe('SessionTitleService', () => { expect(first?.messageSeqs).toEqual([eligible.seq]) expect(ctx.sessionTitle.get(session)).toEqual(first) - expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'session/title')).toHaveLength(1) }) it('folds the latest title event during replay', () => { @@ -160,7 +160,7 @@ describe('SessionTitleService', () => { }, }) - expect(foldSessionTitle(seed.events)).toEqual({ + expect(foldSessionTitle(seed.snapshotEvents())).toEqual({ title: 'Later', messageSeqs: [1, 4], source: { @@ -169,13 +169,13 @@ describe('SessionTitleService', () => { model: { provider: 'mock', model: 'title-model' }, }, eventSeq: 1, - updatedAt: seed.events[1]?.time, + updatedAt: seed.snapshotEvents()[1]?.time, }) }) it('folds an empty or title-less log to undefined', () => { expect(foldSessionTitle([])).toBeUndefined() const empty = Session.create(SessionId('no-title')) - expect(foldSessionTitle(empty.events)).toBeUndefined() + expect(foldSessionTitle(empty.snapshotEvents())).toBeUndefined() }) }) diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index c645230ac2..ac54cc7c06 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -61,7 +61,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { } function events(agent: Agent): SessionEvent[] { - return [...agent.session.events] + return [...agent.session.snapshotEvents()] } /** Find a session event by type, narrowed; throws when absent. */ diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index d4c0a17203..01200022e0 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -214,7 +214,7 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + snapshotEvents: () => events, append: (type: string, data: Record) => { const event = { type, data } events.push(event) @@ -625,7 +625,7 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index e1d15a1bae..67ac413252 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -247,7 +247,7 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, - events, + snapshotEvents: () => events, append: (type: string, data: Record) => { const event = { type, data } events.push(event) @@ -270,7 +270,7 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 }, events: [] }, + session: { id, header: { version: 0, id, createdAt: 0 }, snapshotEvents: () => [] }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -602,7 +602,7 @@ describe('sandbox escalation through ctx.approval', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, }) diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 222e8a0ace..c620afd9c5 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -338,7 +338,7 @@ function digestCatalogEntries(entries: SkillCatalogSource['entries']): string { * Entries of one durable catalog message, or undefined when the record is not a * usable catalog. * - * `agent.session.events` may be a resumed, forked, or externally written seed, + * `agent.session.snapshotEvents()` may contain a resumed, forked, or externally written seed, * and seed validation only guarantees a source object with a non-empty `kind`; * no per-kind field is checked there. An unreadable record is therefore treated * as "not this plugin's catalog" — the posture the replaced content digest had — @@ -360,12 +360,12 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } { const visible = new Set(agent.session.surface.nodes) - const events = agent.session.events let published = false - for (let index = events.length - 1; index >= 0; index -= 1) { - // The loop bounds prove the read-only event view contains this index. - // oxlint-disable-next-line typescript/no-non-null-assertion - const event = events[index]! + for (let index = agent.session.seq - 1; index >= 0; index -= 1) { + const event = agent.session.eventAt(index) + if (event === undefined) { + throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) + } if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue const entries = readCatalogEntries(event.data.source) if (entries === undefined) continue diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 134fc85a62..c19e5be4e6 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -110,7 +110,7 @@ async function proposeStep( } function catalogMessages(session: Session): Extract[] { - return session.events.filter((event): event is Extract => event.type === 'user/message' + return session.snapshotEvents().filter((event): event is Extract => event.type === 'user/message' && event.data.source.kind === 'skill-catalog') } @@ -544,7 +544,7 @@ describe('dsh-tool-skill', () => { }) it('treats a malformed durable catalog as unrecognizable instead of failing the step', async () => { - // Seeds reach `agent.session.events` from JSONL/SQLite on resume or fork, + // Seeds reach `agent.session.snapshotEvents()` from JSONL/SQLite on resume or fork, // and seed validation only guarantees a source object with a non-empty // `kind`. A catalog whose entries are missing or wrongly shaped must be // skipped like any foreign record; throwing here would fail every later diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 9786f585fa..51dd2f44ba 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -46,7 +46,7 @@ export const Config: z = z.object({ * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ function completedTurnPrefix(parent: Agent): SessionEvent[] { - const events = parent.session.events + const events = parent.session.snapshotEvents() const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] // seq === array index (the append contract), so slice up to and including it. diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index f14c5d001b..67b0dd9d70 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -69,7 +69,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // Parent does one real turn first, so the fork has a completed turn to seed. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q1' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length // Delegate to a fresh spawn child. const spawnRun = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) @@ -90,7 +90,7 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) // The fork child inherited the parent's prefix; the spawn child did not. - expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + expect(forkChild.session.snapshotEvents().slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) await spawnRun.dispose() await forkRun.dispose() @@ -98,12 +98,12 @@ describe('multi-subagent coexistence (spawn + fork on one context)', () => { // The parent is unaffected and keeps working after both delegations. parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + const lastParentMessage = parent.session.snapshotEvents().findLast(e => e.type === 'assistant/message') expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.message.content)).toBe('parent turn two') // The parent's OWN log never recorded the children's internal steps — its // only subagent-related entries would be tool/call+tool/result IF it had // used the tool, but here we called the service directly, so the parent log // is purely its own two turns. - expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(parent.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(2) }) }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index 1d4a98e60e..ca2618dd7a 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -85,7 +85,7 @@ describe('dsh-subagent-fork-in-process', () => { expect(text(result.output)).toBe('fresh child') const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. - expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'turn/end')).toHaveLength(1) expect(child.session.header.seedLength).toBeUndefined() await run.dispose() }) @@ -96,14 +96,14 @@ describe('dsh-subagent-fork-in-process', () => { await parent.whenIdle() parent.followup(createUserMessage({ content: [{ type: 'text', text: 'q2' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) await run.result const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(parentPrefixLen) - expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') - expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.snapshotEvents().slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) @@ -111,7 +111,7 @@ describe('dsh-subagent-fork-in-process', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentPrefixLen = parent.session.events.length + const parentPrefixLen = parent.session.snapshotEvents().length const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) const result = await run.result @@ -120,9 +120,9 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's log STARTS with the parent's prefix (seeded), then its own turn. - expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + expect(child.session.snapshotEvents().length).toBeGreaterThan(parentPrefixLen) // The seeded prefix carried the parent's user message. - const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + const seededUser = child.session.snapshotEvents().slice(0, parentPrefixLen).find(e => e.type === 'user/message') expect(seededUser).toBeDefined() // Lineage stamped. expect(child.session.header.parentSession).toBe(parent.session.header.id) @@ -152,7 +152,7 @@ describe('dsh-subagent-fork-in-process', () => { const child = ctx.agents.get(run.id)! // The child's seed has exactly the ONE completed parent turn (the open one excluded). - const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + const seedTurnEnds = child.session.snapshotEvents().filter(e => e.type === 'turn/end') // 1 from the seeded parent turn + 1 from the child's own completed turn. expect(seedTurnEnds.length).toBe(2) diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index fd1ebff2be..7baa1069fd 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -211,7 +211,7 @@ function readResult( cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its // awaited first turn almost immediately, and the owner's own teardown is the // `cancelled` flag below. A cancellation with no accounting turn resolves diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index 4ff72cba21..a30107342e 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -71,7 +71,7 @@ function spawnRequest(parent: Agent) { } function toolResultTexts(agent: Agent): string[] { - return agent.session.events + return agent.session.snapshotEvents() .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') .map(event => event.data.message.content .flatMap(block => block.content) @@ -88,7 +88,7 @@ describe('in-process policy inheritance', () => { setSandboxMode(parent.session, 'read-only') // No parent approval override: the child pin must not depend on one. expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() - const parentLogLength = parent.session.events.length + const parentLogLength = parent.session.snapshotEvents().length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), textResponse('child done'), @@ -102,7 +102,7 @@ describe('in-process policy inheritance', () => { await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) expect(toolResultTexts(child).join('\n')).toContain(READ_ONLY_DENIAL) expect(result.stopReason).toBe('completed') - expect(child.session.events.slice(0, 2)).toMatchObject([ + expect(child.session.snapshotEvents().slice(0, 2)).toMatchObject([ { type: 'sandbox/mode', seq: 0, data: { mode: 'read-only', source: 'delegation' } }, { type: 'approval/policy', seq: 1, data: { policy: 'never', source: 'delegation' } }, ]) @@ -110,10 +110,10 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBeUndefined() expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') expect(ctx.approval.overrideOf(child.session)).toBe('never') - const request = child.session.events.find( + const request = child.session.snapshotEvents().find( (event): event is SessionEvent<'request/header'> => event.type === 'request/header', ) - const runtimeContext = child.session.events.find( + const runtimeContext = child.session.snapshotEvents().find( (event): event is SessionEvent<'user/message'> => event.type === 'user/message' && event.data.source.kind === 'plugin' && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', @@ -130,7 +130,7 @@ describe('in-process policy inheritance', () => { expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') expect(request.data.header.system).not.toContain('You are a delegated subagent') - expect(parent.session.events).toHaveLength(parentLogLength) + expect(parent.session.snapshotEvents()).toHaveLength(parentLogLength) } finally { await run.dispose() } @@ -141,7 +141,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - const seed = [...parent.session.events] + const seed = [...parent.session.snapshotEvents()] setSandboxMode(parent.session, 'read-only') script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -156,7 +156,7 @@ describe('in-process policy inheritance', () => { expect(child.session.header.seedLength).toBe(1) expect(child.session.firstLiveSeq).toBe(seed.length) // seq 1 is the constructor's end-seed marker. - expect(child.session.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + expect(child.session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toMatchObject([ { seq: 0, data: { mode: 'workspace-write' } }, { seq: 2, data: { mode: 'read-only', source: 'delegation' } }, ]) @@ -202,8 +202,8 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) - expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + expect(child.session.snapshotEvents().some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.snapshotEvents().filter(event => event.type === 'approval/policy')).toMatchObject([ { seq: 0, data: { policy: 'never', source: 'delegation' } }, ]) expect(child.session.firstLiveSeq).toBe(0) @@ -242,10 +242,10 @@ describe('in-process policy inheritance', () => { expect(consulted).toBe(false) expect(toolResultTexts(child).join('\n')) .toContain('the user rejected escalating this operation to "workspace-write"') - const asked = child.session.events.find( + const asked = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', ) - const decided = child.session.events.find( + const decided = child.session.snapshotEvents().find( (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', ) expect(asked?.data.toolName).toBe('write') diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index e912c2516f..d34729037c 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -87,7 +87,7 @@ describe('a child agent composed in-process', () => { const run = await startInProcessRun(spawnRequest(parent), {}) await run.result - expect(run.localAgent?.session.events.some(event => + expect(run.localAgent?.session.snapshotEvents().some(event => event.type === 'request/header' && JSON.stringify(event.data).includes('section for preset_only'))).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 731ada1fd9..41f86d099f 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -191,7 +191,7 @@ describe('in-process structured output', () => { expect(result.structured).toEqual({ answer: 5 }) expect(sideEffectRan).toBe(false) const child = ctx.agents.get(run.id) - const sideEffectResult = child?.session.events.find(event => + const sideEffectResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c2') expect(sideEffectResult?.type === 'tool/result' && sideEffectResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -235,7 +235,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('completed') // The child's log carries the isError tool/result for the invalid call. const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results.length).toBe(2) expect(results[0]!.data.message.content[0].isError).toBe(true) await run.dispose() @@ -253,7 +253,7 @@ describe('in-process structured output', () => { // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) + expect(child.session.snapshotEvents().filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) @@ -318,7 +318,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') // ...the logged tool result is the blocked isError with the feedback... const child = ctx.agents.get(run.id)! - const results = child.session.events.filter(e => e.type === 'tool/result') + const results = child.session.snapshotEvents().filter(e => e.type === 'tool/result') expect(results[0]!.data.message.content[0].isError).toBe(true) expect(JSON.stringify(results[0]!.data.message.content)).toContain('capture rejected by hook') // ...and the turn CONTINUED past the blocked call (no captured veto): @@ -363,7 +363,7 @@ describe('in-process structured output', () => { expect(result.structured).toBeUndefined() expect(result.stopReason).toBe('error') const child = ctx.agents.get(run.id) - const captureResult = child?.session.events.find(event => + const captureResult = child?.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === 'c1') expect(captureResult?.type === 'tool/result' && captureResult.data.message.content[0].isError).toBe(true) await run.dispose() @@ -434,7 +434,7 @@ describe('in-process structured output', () => { expect(result.stopReason).toBe('error') expect(adapter.requests).toHaveLength(2) const child = ctx.agents.get(run.id)! - const outer = child.session.events.find(event => + const outer = child.session.snapshotEvents().find(event => event.type === 'tool/result' && event.data.message.source.callId === ToolCallId('c1')) expect(outer?.type === 'tool/result' && outer.data.message.content[0].isError).toBe(true) await run.dispose() diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index b69a549983..a4e4cb61f6 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -152,7 +152,7 @@ describe('startInProcessRun', () => { let injected = false ctx.on('session/flush', (session) => { if (injected || session.header.parentSession === undefined) return - const lastEnd = session.events.findLast(event => event.type === 'turn/end') + const lastEnd = session.snapshotEvents().findLast(event => event.type === 'turn/end') if (lastEnd?.type !== 'turn/end' || lastEnd.data.reason.kind !== 'max-tokens') return injected = true session.append('user/message', createUserMessage({ @@ -166,7 +166,7 @@ describe('startInProcessRun', () => { const child = ctx.agents.get(run.id)! expect(injected).toBe(false) - expect(child.session.events.findLast(event => event.type === 'turn/end')) + expect(child.session.snapshotEvents().findLast(event => event.type === 'turn/end')) .toMatchObject({ data: { reason: { kind: 'max-tokens' } } }) expect(result.stopReason).toBe('max-tokens') await run.dispose() @@ -201,13 +201,13 @@ describe('startInProcessRun', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const seed = parent.session.events.slice() + const seed = parent.session.snapshotEvents().slice() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result expect(text(result.output)).toBe('child answer') const child = ctx.agents.get(run.id)! expect(child.session.header.seedLength).toBe(seed.length) - expect(child.session.events.slice(0, seed.length)).toEqual(seed) + expect(child.session.snapshotEvents().slice(0, seed.length)).toEqual(seed) await run.dispose() }) @@ -328,7 +328,7 @@ describe('startInProcessRun', () => { }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) - const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') + const turnEnd = child?.session.snapshotEvents().findLast(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'parent' } }) await signalled.dispose() diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index ba76c69203..ee2c79e1e1 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( // The parent's log records the subagent tool/call + its result (not the // child's internal steps). - const events = [...parent.session.events] + const events = [...parent.session.snapshotEvents()] const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index b3efa062e7..c46f99514f 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -111,14 +111,14 @@ describe('dsh-subagent-spawn-in-process', () => { const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent prompt' }], source: { kind: 'user' } })) await parent.whenIdle() - const parentEventCount = parent.session.events.length + const parentEventCount = parent.session.snapshotEvents().length expect(parentEventCount).toBeGreaterThan(0) const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) await run.result const child = ctx.agents.get(run.id)! // The child's first user/message is its OWN prompt, not the parent's history. - const firstUser = child.session.events.find(e => e.type === 'user/message') + const firstUser = child.session.snapshotEvents().find(e => e.type === 'user/message') expect(firstUser).toBeDefined() await run.dispose() }) @@ -434,7 +434,7 @@ describe('dsh-subagent-spawn-in-process', () => { expect((childRequest.tools ?? []).map(t => t.name)).not.toContain('forbidden_tool') // …and the attempted call executed as UNKNOWN_TOOL (visible in the log). const child = ctx.agents.get(run.id)! - const toolResult = child.session.events.find(e => e.type === 'tool/result')! + const toolResult = child.session.snapshotEvents().find(e => e.type === 'tool/result')! expect(JSON.stringify(toolResult.data)).toContain('unknown tool') await run.dispose() }) diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index a6b5dcf3e1..1b5c90bb07 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -27,5 +27,5 @@ export function seedDescriptorTurn( ): SessionEvent[] { const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) - return [...staged.events] + return [...staged.snapshotEvents()] } diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 2e1d513d5a..2f4e6d5392 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -193,11 +193,11 @@ export function createActivationObserver( : { stopReason: 'error' } return { start: (child: Agent): void => { - boundary = child.session.events.length + boundary = child.session.seq emit('subagent/start', identity, parent) }, capture: (child: Agent): void => { - const own = child.session.events.slice(boundary) + const own = child.session.snapshotEvents(boundary) const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 31b5bdbcb5..17a61c88b2 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -195,7 +195,7 @@ describe('SubagentRuntime.startContinuable', () => { ctx.on('agent/inbox/inserted', ({ agent, message }) => { // Acceptance is the boundary `startContinuable` resolves at, so observe // the log state exactly there rather than after later microtasks. - enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) + enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.snapshotEvents(), 'child task') }) }) const started = await ctx.subagents.startContinuable(startSpec(parent)) @@ -405,7 +405,7 @@ describe('SubagentRuntime.startContinuable', () => { expect(found).toBeDefined() return found! }) - const descriptor = child.session.events.find(event => event.type === 'subagent/descriptor') + const descriptor = child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor') expect(descriptor?.data).toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, @@ -440,7 +440,7 @@ describe('SubagentRuntime.startContinuable', () => { return found! }) - expect(child.session.events.find(event => event.type === 'subagent/descriptor')?.data) + expect(child.session.snapshotEvents().find(event => event.type === 'subagent/descriptor')?.data) .toEqual({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', @@ -1362,7 +1362,7 @@ describe('continuable review regressions', () => { const started = await ctx.subagents.startContinuable(startSpec(parent)) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) const child = ctx.agents.get(started.childId)! - const before = child.session.events.length + const before = child.session.snapshotEvents().length const controller = new AbortController() controller.abort('caller gave up') @@ -1695,7 +1695,7 @@ describe('continuable review regressions', () => { /** Every settlement notice this agent received, in order, as flat text. */ function settlementNotices(agent: Agent): { sender: string; text: string; summary: string }[] { - const logged = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const logged = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...logged, ...agent.inbox.nextStep, ...agent.inbox.nextTurn].flatMap((message) => { if (message.source.kind !== 'subagent-settled') return [] return [{ @@ -1730,7 +1730,7 @@ describe('continuable report delivery', () => { await vi.waitFor(() => { expect(adapter.requests.filter(request => request.sessionId === parent.id)).toHaveLength(1) }) - const report = parent.session.events.flatMap(event => event.type === 'user/message' + const report = parent.session.snapshotEvents().flatMap(event => event.type === 'user/message' && event.data.source.kind === 'subagent-report' ? [event.data] : [])[0] expect(report?.id).toBe(messageId) @@ -1967,7 +1967,7 @@ describe('continuable settlement delivery', () => { // Turn 1 closed cleanly and no later turn opened, so the cancelled queue is // the only record that this epoch was cut short. - expect(hasUserText(child.session.events, 'never runs')).toBe(false) + expect(hasUserText(child.session.snapshotEvents(), 'never runs')).toBe(false) await vi.waitFor(() => { expect(settlementNotices(parent)).toHaveLength(1) }) expect(settlementNotices(parent)[0]!.text).toBe( `Background subagent ${started.childId} was stopped before it finished.` @@ -2144,8 +2144,8 @@ describe('continuable settlement delivery', () => { `Background subagent ${started.childId} was stopped before it finished.` + '\nIt left no closing message.', ) - expect(parent.session.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced')).toBe(true) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) expect(parent.status).toBe('idle') }) @@ -2161,7 +2161,7 @@ describe('continuable settlement delivery', () => { await drained expect(settlementNotices(parent)).toHaveLength(1) - expect(parent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(parent.session.snapshotEvents().some(event => event.type === 'turn/start')).toBe(false) }) it('records but cannot deliver a teardown notice once the parent is disposed too', async () => { diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 30a5568d94..91ca89bd6d 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -140,7 +140,7 @@ function registerReportConflict(child: Agent): () => void { /** Reports already visible or still pending in one Agent. */ function reports(agent: Agent): { id: string; text: string; sender: string }[] { - const visible = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : []) + const visible = agent.session.snapshotEvents().flatMap(event => event.type === 'user/message' ? [event.data] : []) return [...visible, ...agent.inbox.nextStep].flatMap((message) => { if (message.source.kind !== 'subagent-report') return [] return [{ diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 5282ee0a63..1aa93df37c 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -306,7 +306,7 @@ describe('SubagentModelSelectionConfig', () => { const enabledSeed = Session.create(SessionId('enabled-seed')) enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) - const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) + const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.snapshotEvents() }) expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) @@ -314,7 +314,7 @@ describe('SubagentModelSelectionConfig', () => { enabled: true, allowedModels: ALLOWED_MODELS, }) - const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 148f9e37bd..4799709b3f 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -615,7 +615,7 @@ describe('terminal-bash plugin shape', () => { expect(() => { setSandboxMode(session, 'read-only') }).toThrow( 'cannot change sandbox mode from "danger-full-access" to "read-only" while persistent terminal sessions are open or being created; wait for creation to settle and close them first', ) - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(1) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(1) const replacementFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) const second = await ctx.terminals.spawn(owner, { type: 'stub' }) @@ -625,7 +625,7 @@ describe('terminal-bash plugin shape', () => { await ctx.terminals.kill(owner, created.sessionId) await ctx.terminals.kill(owner, second.sessionId) expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow() - expect(session.events.filter(event => event.type === 'sandbox/mode')).toHaveLength(2) + expect(session.snapshotEvents().filter(event => event.type === 'sandbox/mode')).toHaveLength(2) }) it('also fences sandbox-mode changes across unpublished PTY creation', async () => { diff --git a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts index 916b61aa0d..6830cbaf4b 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/workspace-context-compaction.ts @@ -19,7 +19,7 @@ export function apply(ctx: Context): void { || exec.arguments.file_path !== 'nested/task.txt') return downstream const agent = exec.agent const baseline = agent.session.surface.nodes - .map(seq => agent.session.events[seq]) + .map(seq => agent.session.snapshotEvents()[seq]) .find(event => event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline === true) diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index fee7088a40..21970cbcb9 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -60,7 +60,7 @@ function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFai /** Validate one existing log in a single pass and return its tail trace. */ function seedTrace(session: Session, fail: InvariantFailure): TurnTrace { const trace: TurnTrace = { open: false } - for (const event of session.events) { + for (const event of session.snapshotEvents()) { validateEvent(event, trace, fail) advanceTrace(trace, event) } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 0692772ea9..2ca91bb10f 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -65,7 +65,7 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan a two-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const log = agent.session.events + const log = agent.session.snapshotEvents() expect(findEvent(log, 'tool/call').data.name).toBe('todo_write') expect(findEvent(log, 'tool/result').data.message.content[0].isError).toBe(false) @@ -93,9 +93,9 @@ describe('todo_write tool through the agent loop', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plan then update' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const todoEvents = agent.session.events.filter(e => e.type === 'todo/write') + const todoEvents = agent.session.snapshotEvents().filter(e => e.type === 'todo/write') expect(todoEvents).toHaveLength(2) - expect(findEvent(agent.session.events, 'todo/write', 'last').data.todos).toEqual([ + expect(findEvent(agent.session.snapshotEvents(), 'todo/write', 'last').data.todos).toEqual([ { content: 'step one', status: 'completed' }, { content: 'step two', status: 'in_progress' }, ]) diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 7d53ad052b..e3d2001554 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,10 +66,10 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const before = [...session.events] + const before = [...session.snapshotEvents()] expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) - expect(session.events).toEqual(before) + expect(session.snapshotEvents()).toEqual(before) }) it('rejects an existing snapshot outside an open turn on late registration', async () => { diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 565269f1e8..d25d02bba9 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -111,7 +111,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { }) expect(result.isError).toBe(true) expect(resultText(result)).toContain('at most one task may be in_progress') - expect(owner.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(owner.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }, 30_000) it('allowParallelInProgress: true permits a parallel write end to end', async () => { @@ -128,7 +128,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { agent: owner, }) expect(result.isError).toBe(false) - expect(owner.session.events.findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) + expect(owner.session.snapshotEvents().findLast(e => e.type === 'todo/write')?.data.todos).toEqual(PARALLEL_TODOS) }, 30_000) it.each([ diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 713ef08e5c..c54f710c33 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -82,7 +82,7 @@ describe('dsh-tool-todo', () => { }) expect(text(result)).toContain('1 pending, 1 in progress, 0 completed') - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual(todos) }) @@ -92,7 +92,7 @@ describe('dsh-tool-todo', () => { const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent }) expect(result.isError).toBe(false) - const event = agent.session.events.findLast(e => e.type === 'todo/write')! + const event = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }]) }) @@ -105,7 +105,7 @@ describe('dsh-tool-todo', () => { { content: 'b', status: 'in_progress' }, ] }, { agent }) - const current = agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'a', status: 'completed' }, { content: 'b', status: 'in_progress' }, @@ -139,7 +139,7 @@ describe('dsh-tool-todo', () => { todos, counts: { pending: 1, inProgress: 2, completed: 0 }, }) - expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) + expect(agent.session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos) }) describe('allowParallelInProgress', () => { @@ -155,7 +155,7 @@ describe('dsh-tool-todo', () => { expect(result.isError).toBe(true) expect(text(result)).toContain('at most one task may be in_progress') // A rejected call must not reach the durable log. - expect(agent.session.events.some(e => e.type === 'todo/write')).toBe(false) + expect(agent.session.snapshotEvents().some(e => e.type === 'todo/write')).toBe(false) }) it('false still accepts one active item', async () => { @@ -247,7 +247,7 @@ describe('todo/write event', () => { ] session.append('todo/write', { todos }) - const event = session.events.findLast(e => e.type === 'todo/write')! + const event = session.snapshotEvents().findLast(e => e.type === 'todo/write')! expect(event.type).toBe('todo/write') expect(event.data.todos).toEqual(todos) @@ -268,7 +268,7 @@ describe('todo/write event', () => { { content: 'second', status: 'in_progress' }, ] }) - const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos + const current = session.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos expect(current).toEqual([ { content: 'first', status: 'completed' }, { content: 'second', status: 'in_progress' }, @@ -293,11 +293,11 @@ describe('todo/write event', () => { original.append('turn/start', { turn: 1 }) original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('t4-replay'), [...original.events]) + const replayed = Session.create(SessionId('t4-replay'), original.snapshotEvents()) - expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos) + expect(replayed.snapshotEvents().findLast(e => e.type === 'todo/write')!.data.todos) .toEqual([{ content: 'only', status: 'completed' }]) - expect(replayed.events.slice(0, original.seq)).toEqual(original.events) + expect(replayed.snapshotEvents(0, original.seq)).toEqual(original.snapshotEvents()) expect(replayed.firstLiveSeq).toBe(original.seq) }) }) diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 4b32a03f66..d36174b94f 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -135,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) + for (const event of session.snapshotEvents().filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 06e8b0511b..e3404d6c12 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -146,7 +146,7 @@ describe('dsh-tool-workflow', () => { engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) expect((await pending).isError).toBe(false) expect(engine.disposed).toBe(1) - expect(session.events.map(event => [event.type, event.data])).toEqual([ + expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([ ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], ['tool-workflow/agent-start', { runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1', @@ -166,10 +166,10 @@ describe('dsh-tool-workflow', () => { value: null, stopReason: 'completed', agentsStarted: 0, }) await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) - expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start']) + expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start']) barrier.resolve(undefined) expect((await pending).isError).toBe(false) - expect(session.events.map(event => event.type)).toEqual([ + expect(session.snapshotEvents().map(event => event.type)).toEqual([ 'tool-workflow/run-start', 'tool-workflow/run-end', ]) }) @@ -190,9 +190,9 @@ describe('dsh-tool-workflow', () => { engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 }) expect((await first).isError).toBe(false) expect((await second).isError).toBe(true) - expect(session.events.filter(event => event.type === 'tool-workflow/agent-start')) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start')) .toHaveLength(1) - expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) + expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) .toEqual([ { runId: 'run-1', stopReason: 'completed' }, { runId: 'run-2', stopReason: 'error' }, @@ -208,7 +208,7 @@ describe('dsh-tool-workflow', () => { await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) expect((await pending).isError).toBe(false) - expect(session.events).toEqual([]) + expect(session.snapshotEvents()).toEqual([]) }) it.each([ @@ -240,7 +240,7 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain(failedType) - const types = session.events.map(event => event.type) + const types = session.snapshotEvents().map(event => event.type) const expectedPrefixes = { 'tool-workflow/run-start': [], 'tool-workflow/agent-start': ['tool-workflow/run-start'], From bcfec8d1c3cc53bbfbf6b11a70babe0dbc44db94 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 13:43:16 +0800 Subject: [PATCH 02/38] perf(session): reuse immutable event snapshots --- .../headless/tests/coding-task.e2e.ts | 2 +- .../profiles/headless/tests/compaction.e2e.ts | 2 +- .../profiles/headless/tests/full-loop.e2e.ts | 2 +- .../tests/profiles/headless/tests/harness.ts | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 4 +- .../profiles/headless/tests/resume.e2e.ts | 2 +- .../profiles/headless/tests/todo-write.e2e.ts | 2 +- .../api/session-controller/src/commands.ts | 4 +- packages/api/session-controller/src/index.ts | 4 +- .../tests/compaction-basic.spec.ts | 2 +- .../tests/compaction-loop-repro.spec.ts | 10 ++--- .../tests/manual-compaction.spec.ts | 6 +-- .../tests/tool-result-pruner.spec.ts | 2 +- .../context/agent-instructions/src/state.ts | 4 +- .../tests/agent-instructions.e2e.ts | 8 ++-- .../tests/agent-instructions.spec.ts | 40 +++++++++--------- .../time-context/tests/time-context.spec.ts | 2 +- .../tests/contract-regressions.spec.ts | 42 +++++++++---------- .../agent-loop/tests/interception.spec.ts | 4 +- packages/core/agent-loop/tests/loop.spec.ts | 2 +- .../agent-loop/tests/request-cache.e2e.ts | 2 +- .../tests/request-reconstruction.spec.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 2 +- .../core/agent-loop/tests/tool-calls.spec.ts | 4 +- .../core/session/tests/derived-cache.spec.ts | 2 +- .../core/session/tests/properties.spec.ts | 6 +-- packages/core/session/tests/session.spec.ts | 4 +- packages/core/session/tests/surface.spec.ts | 2 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 2 +- .../tests/repeat-tool-reminder.spec.ts | 6 +-- .../hooks/hook-protocol/tests/events.spec.ts | 18 ++++---- .../hooks-claude-code/tests/bridge.spec.ts | 4 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 2 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 6 +-- .../shell/tool-bash/tests/integration.spec.ts | 6 +-- .../subagent-in-process-driver/src/index.ts | 2 +- .../tests/inheritance.spec.ts | 2 +- .../tests/subagent-in-process-driver.spec.ts | 2 +- .../tests/spawn-in-process.e2e.ts | 2 +- .../subagent/subagent/src/descriptor-seed.ts | 4 +- .../todo/tool-todo/tests/invariant.spec.ts | 2 +- 43 files changed, 116 insertions(+), 116 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts index 7f8e09c6c5..f6155102e3 100644 --- a/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/coding-task.e2e.ts @@ -67,7 +67,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test await waitForIdle(ctx, agent) // The agent claims success… - const summary = finalText([...agent.session.snapshotEvents()]).toLowerCase() + const summary = finalText(agent.session.snapshotEvents()).toLowerCase() expect(summary.length).toBeGreaterThan(0) // …and the world agrees: the test passes when WE run it, and the test diff --git a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts index 52889ce645..7b22be2958 100644 --- a/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/compaction.e2e.ts @@ -56,7 +56,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // A compaction ran: the start…end bracket landed in the real log. const starts = events.filter(e => e.type === 'compaction/start') diff --git a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts index 22ca1e1976..cf55e35d28 100644 --- a/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/full-loop.e2e.ts @@ -34,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const calls = events.filter(event => event.type === 'tool/call') expect(calls.length).toBeGreaterThan(0) expect(calls.some(event => event.data.name === 'bash')).toBe(true) diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index 495b7ebbd2..e09be01d0e 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -96,7 +96,7 @@ export function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -export function finalText(events: SessionEvent[]): string { +export function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 2b0306fe7f..fba29b93b3 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -366,7 +366,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr + 'and return only the joined string.', }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events: SessionEvent[] = [...agent.session.snapshotEvents()] + const events: readonly SessionEvent[] = agent.session.snapshotEvents() // The wire contract: every request this session made offered EXACTLY ONE // tool — run_code (the logged header snapshots the assembled list). @@ -418,7 +418,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - const events: SessionEvent[] = [...handle.agent.session.snapshotEvents()] + const events: readonly SessionEvent[] = handle.agent.session.snapshotEvents() const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = await vi.waitFor(() => { diff --git a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts index fc4e815d2e..a47c01c4b6 100644 --- a/apps/cli/tests/profiles/headless/tests/resume.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/resume.e2e.ts @@ -63,6 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. - expect(finalText([...resumed.session.snapshotEvents()])).toContain(SECRET) + expect(finalText(resumed.session.snapshotEvents())).toContain(SECRET) }, 180_000) }) diff --git a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts index eaff804410..5fa792b672 100644 --- a/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/todo-write.e2e.ts @@ -37,7 +37,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a + 'Send all three in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // The model actually called the tool. const calls = events.filter(event => event.type === 'tool/call') diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index efbf02d60f..1e4f236928 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -48,7 +48,7 @@ import type { interface SessionReadState { readonly id: SessionId readonly header: SessionHeader - readonly events: SessionEvent[] + readonly events: readonly SessionEvent[] } /** Implements Session business commands delegated by the Session Controller Remote service. */ @@ -484,7 +484,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return { id: attached.id, header: attached.header, events: [...attached.snapshotEvents()] } + return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b18d013711..01df3942ee 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -191,10 +191,10 @@ export class SessionController extends TypertRemoteService { inspect( sessionId: SessionId, signal?: AbortSignal, - ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { - return Promise.resolve({ meta: attached.header, events: [...attached.snapshotEvents()] }) + return Promise.resolve({ meta: attached.header, events: attached.snapshotEvents() }) } return inspectApiSession(this.ctx, sessionId, signal) } diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 89e62c0db5..20125c18c6 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -882,7 +882,7 @@ describe('compaction region transaction', () => { expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') expect(head.content.at(-1)).toEqual({ type: 'text', text: '' }) - const replay = Session.create(SessionId('replay'), [...session.snapshotEvents()]) + const replay = Session.create(SessionId('replay'), session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 7d44f6e268..f7b44e1940 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -188,7 +188,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function overflowHistorySeed(): SessionEvent[] { +function overflowHistorySeed(): readonly SessionEvent[] { const session = Session.create(SessionId('overflow-history-seed')) for (let turn = 1; turn <= 2; turn += 1) { const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' @@ -215,7 +215,7 @@ function overflowHistorySeed(): SessionEvent[] { session.append('step/end', { turn, step: 1 }) session.append('turn/end', { turn, reason: { kind: 'completed' } }) } - return [...session.snapshotEvents()] + return session.snapshotEvents() } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { @@ -250,7 +250,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const compactStart = events.find(event => event.type === 'compaction/start') expect(compactStart).toBeDefined() const precedingResult = events.findLast(event => @@ -282,7 +282,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() // A compaction ran: at least one checkpoint landed on the surface. const checkpoints = events.filter( (e): e is SurfaceEvent => @@ -356,7 +356,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', expect(retry).toContain('RECOVERY CHECKPOINT') expect(retry).not.toContain('OLD HISTORY SENTINEL') - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const stepStart = events.find(event => event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, )! diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index d5d958b3fc..84397a5ada 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -442,7 +442,7 @@ describe('compactNow transaction and failure classification', () => { compactionId: CompactionId('stale-manual-compaction'), turn: null, }) - const reloaded = Session.create(SessionId('stale-orphan'), [...original.snapshotEvents()]) + const reloaded = Session.create(SessionId('stale-orphan'), original.snapshotEvents()) const boundary = reloaded.snapshotEvents().findLast(event => event.type === 'session/end-seed') const orphan = reloaded.snapshotEvents().find(event => event.type === 'compaction/start') const agent = fakeAgent(reloaded, () => () => undefined) @@ -461,7 +461,7 @@ describe('compactNow transaction and failure classification', () => { }) original.append('turn/start', { turn: 3 }) original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } }) - const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.snapshotEvents()]) + const reloaded = Session.create(SessionId('reloaded-orphan'), original.snapshotEvents()) const agent = fakeAgent(reloaded, () => () => undefined) await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull() @@ -716,7 +716,7 @@ describe('compactNow transaction and failure classification', () => { const reserve = vi.fn(() => testCase.release) const measure = vi.spyOn(ctx.tokenMeter, 'measure') const agent = fakeAgent(testCase.session, reserve) - const before = [...testCase.session.snapshotEvents()] + const before = testCase.session.snapshotEvents() const reason = Object.freeze({ kind: 'cancelled', case: testCase.name }) const controller = new AbortController() controller.abort(reason) diff --git a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts index 1235b8fa2c..2fc6bb9dd4 100644 --- a/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts +++ b/packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts @@ -254,7 +254,7 @@ describe('ToolResultPruner session transaction', () => { turn: 2, }) service().pruneSession(session) - const replay = Session.create(session.id, [...session.snapshotEvents()]) + const replay = Session.create(session.id, session.snapshotEvents()) expect(replay.deriveMessages()).toEqual(session.deriveMessages()) expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration) }) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index ce0118a569..93e6bb3d10 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,11 +139,11 @@ function visibleInstructionChanges( ): Map { const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const [seq, event] of agent.session.snapshotEvents().entries()) { + for (const event of agent.session.snapshotEvents()) { if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - if (visibleSeqs.has(seq)) visible.set(change.scope, change) + if (visibleSeqs.has(event.seq)) visible.set(change.scope, change) } } for (const message of authorityMessages) { diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index a5cb2ae94c..c24dbcac75 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -68,7 +68,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function finalText(events: SessionEvent[]): string { +function finalText(events: readonly SessionEvent[]): string { const message = events.findLast(event => event.type === 'assistant/message') if (message?.type !== 'assistant/message') return '' return message.data.message.content @@ -84,7 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.snapshotEvents()])).toContain(PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(PROBE) }, 120_000) it('loads a nested AGENTS.md after the real read tool touches a descendant file', async () => { @@ -96,7 +96,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Use the read tool to inspect pkg/deep/file.txt. After reading it, answer: nested instruction handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - expect(finalText([...live.agent.session.snapshotEvents()])).toContain(NESTED_PROBE) + expect(finalText(live.agent.session.snapshotEvents())).toContain(NESTED_PROBE) }, 120_000) it('appends changed baseline instructions after a real file-tool touch without rewriting the frozen prefix', async () => { @@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode live.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'You must use the read tool to inspect trigger.txt. After reading it, answer: updated workspace context handshake?' }], source: { kind: 'user' } })) await waitForIdle(live.ctx, live.agent) - const events = [...live.agent.session.snapshotEvents()] + const events = live.agent.session.snapshotEvents() const update = events.find(event => event.type === 'user/message' && event.data.source.kind === 'agent-instructions' && event.data.source.baseline !== true) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 9855748a73..896f12d532 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -187,7 +187,7 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace return mountWorkspaceContextPlugin(ctx, config) } -function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { +function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Agent { const id = SessionId('s1') const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { @@ -1115,9 +1115,9 @@ describe('workspace context request injection', () => { const original = stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, [...original.session.snapshotEvents()]) + const firstResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, [...firstResume.session.snapshotEvents()]) + const secondResume = stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) @@ -1144,7 +1144,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1170,9 +1170,9 @@ describe('workspace context request injection', () => { const original = stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, [...original.session.snapshotEvents()]) + const firstResume = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, [...firstResume.session.snapshotEvents()]) + const secondResume = stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) @@ -1200,7 +1200,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, [...original.session.snapshotEvents()]) + const resumed = stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1237,7 +1237,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1256,7 +1256,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1290,7 +1290,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, [...original.session.snapshotEvents()]) + const claudeResume = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1305,7 +1305,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, [...claudeResume.session.snapshotEvents()]) + const restored = stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1340,7 +1340,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1354,7 +1354,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, [...resumed.session.snapshotEvents()]) + const repeated = stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1384,7 +1384,7 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(ctx, resumed).waterfall( @@ -1430,7 +1430,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) const staleClaim = resumed.inbox.claim('next-step', 1) const staleDecision = await agentEvents(ctx, resumed).waterfall( @@ -1483,7 +1483,7 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) const claimed = resumed.inbox.claim('next-step', 1) const decision = await agentEvents(resumedCtx, resumed).waterfall( @@ -1790,7 +1790,7 @@ describe('workspace context request injection', () => { // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -3540,7 +3540,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, [...agent.session.snapshotEvents()]) + const resumed = stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3574,7 +3574,7 @@ describe('dynamic nested workspace context injection', () => { }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) @@ -4608,7 +4608,7 @@ describe('workspace context inbox synchronization', () => { callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, [...original.session.snapshotEvents()]) + const resumed = stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ signal: testToolSignal, diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index f461998377..ec8976fead 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -304,7 +304,7 @@ describe('durable step context', () => { original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) expect(JSON.stringify(original.deriveMessages())).not.toContain('Time sampled while preparing') - const resumed = Session.create(SessionId('resumed'), [...original.snapshotEvents()]) + const resumed = Session.create(SessionId('resumed'), original.snapshotEvents()) const resumedAgent = sessionAgent(resumed) vi.setSystemTime(BASE + 999) openMessageTurn(resumed, 2) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 489fd644e2..35e5512e08 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -175,7 +175,7 @@ describe('abort during tool execution ends the turn', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() expect(events .filter(event => event.type === 'tool/result' || (event.type === 'user/message' && event.data.source.kind === 'plugin') @@ -534,7 +534,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.snapshotEvents()] }) + const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() }) const forked = new ReactLoopAgent( ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, ) @@ -603,7 +603,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () expect(errors[0]).toBeInstanceOf(LlmError) expect((errors[0] as LlmError).failure).toEqual(failure) - const events = [...agent.session.snapshotEvents()] + const events = agent.session.snapshotEvents() const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } }) // A failed step must not synthesize an assistant message. @@ -625,7 +625,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () await waitForIdle(ctx, agent) expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }]) - expect([...agent.session.snapshotEvents()].some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.snapshotEvents().some(event => event.type === 'assistant/message')).toBe(false) }) it('handles a finish error without a code (code key omitted)', async () => { @@ -655,7 +655,7 @@ describe('step boundary publication order', () => { const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return - const events = [...subject.snapshotEvents()] + const events = subject.snapshotEvents() const last = events.at(-1) observed.push({ turn: event.data.turn, @@ -691,7 +691,7 @@ describe('turn and step boundary recovery', () => { /** Count turn/step boundary events for balance assertions. */ function boundaryCounts(agent: Agent) { - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() return { turnStart: e.filter(x => x.type === 'turn/start').length, turnEnd: e.filter(x => x.type === 'turn/end').length, @@ -721,7 +721,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const c = boundaryCounts(agent) expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 }) expect(errors).toEqual([]) @@ -878,7 +878,7 @@ describe('turn and step boundary recovery', () => { await fiber.dispose() // dispose during the hanging step await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const turnStarts = e.filter(x => x.type === 'turn/start').length const turnEnds = e.filter(x => x.type === 'turn/end').length expect(turnStarts).toBe(1) @@ -911,7 +911,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await agent.whenIdle() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) @@ -940,10 +940,10 @@ describe('turn and step boundary recovery', () => { expect(errors).toEqual([]) // Session contains the observer failure per listener, so the committed turn // remains visible to later observers and executes normally. - const types = [...agent.session.snapshotEvents()].map(e => e.type) + const types = agent.session.snapshotEvents().map(e => e.type) expect(types.filter(t => t === 'turn/start')).toHaveLength(1) expect(types.filter(t => t === 'turn/end')).toHaveLength(1) - const lastBoundary = [...agent.session.snapshotEvents()].reverse().find(e => e.type === 'turn/start' || e.type === 'turn/end') + const lastBoundary = agent.session.snapshotEvents().findLast(e => e.type === 'turn/start' || e.type === 'turn/end') expect(lastBoundary?.type).toBe('turn/end') expect(agent.session.snapshotEvents().at(-1)?.type).toBe('turn/end') @@ -977,7 +977,7 @@ describe('turn and step boundary recovery', () => { .toEqual({ kind: 'completed' }) // step/end precedes turn/end (ordering contract) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() const stepEndIdx = e.findIndex(x => x.type === 'step/end') const turnEndIdx = e.findIndex(x => x.type === 'turn/end') expect(stepEndIdx).toBeGreaterThanOrEqual(0) @@ -1011,7 +1011,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() // Both step/end and turn/end are present — finalization ran to completion. expect(e.some(x => x.type === 'step/end')).toBe(true) expect(e.some(x => x.type === 'turn/end')).toBe(true) @@ -1041,7 +1041,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await waitForIdle(ctx, agent) // turn 1 is balanced despite the throwing turn/end listener. - const e1 = [...agent.session.snapshotEvents()] + const e1 = agent.session.snapshotEvents() expect(e1.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e1.filter(x => x.type === 'turn/end')).toHaveLength(1) expect(e1.at(-1)?.type).toBe('turn/end') @@ -1050,7 +1050,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'again') await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) - expect([...agent.session.snapshotEvents()].filter(x => x.type === 'turn/end')).toHaveLength(2) + expect(agent.session.snapshotEvents().filter(x => x.type === 'turn/end')).toHaveLength(2) }) }) @@ -1082,7 +1082,7 @@ describe('tool result call identity', () => { await waitForIdle(ctx, agent) // The logged tool/result.callId is the originating call.id. - const resultEvent = [...agent.session.snapshotEvents()].find(e => e.type === 'tool/result') + const resultEvent = agent.session.snapshotEvents().find(e => e.type === 'tool/result') expect(resultEvent?.type).toBe('tool/result') if (resultEvent?.type === 'tool/result') { expect(resultEvent.data.message.source.callId).toBe(ToolCallId('c1')) @@ -1146,7 +1146,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1194,7 +1194,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) unlisten() - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1244,7 +1244,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await driverDone(agent) // The post-listener cancellation check catches disposal before any step or LLM call. - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1291,7 +1291,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await fiber.dispose() await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.some(x => x.type === 'step/start')).toBe(false) @@ -1336,7 +1336,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await disposalDone await driverDone(agent) - const e = [...agent.session.snapshotEvents()] + const e = agent.session.snapshotEvents() expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type)) .toEqual(['turn/start', 'turn/end']) expect(e.find(x => x.type === 'turn/end')?.data.reason) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 101e082f99..d836d6e8f9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -56,8 +56,8 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } describe('agent/pre-step', () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 0158a2fa15..d3235a3970 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1526,7 +1526,7 @@ describe('agent loop', () => { send(agent, 'run') await waitForIdle(ctx, agent) - const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.snapshotEvents()] }) + const replayed = ctx.sessions.create(SessionId('replayed'), { seed: agent.session.snapshotEvents() }) expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages()) // event-by-event identity of types over the inherited prefix expect(replayed.snapshotEvents().slice(0, agent.session.seq).map(e => e.type)).toEqual( diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index dd1548cd7c..e8f0bab180 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -82,7 +82,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits ( agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Thanks. Repeat that value one more time.' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const usages = [...agent.session.snapshotEvents()] + const usages = agent.session.snapshotEvents() .filter(e => e.type === 'assistant/message') .map(e => e.data.usage) expect(usages.length).toBeGreaterThanOrEqual(3) // 2 steps in turn 1 + ≥1 in turn 2 diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 3b426c0ca8..d6332965ec 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -650,7 +650,7 @@ describe('request stability across the loop', () => { const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ sessionId: SessionId('gen2-session'), - seed: [...agent.session.snapshotEvents()], + seed: agent.session.snapshotEvents(), agentOptions: { provider: 'mock', model: 'mock' }, }) const agent2 = handle.agent diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 4688a97fa8..320d5ce81f 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -650,7 +650,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.followup(createUserMessage({ content: [{ type: 'text', text: 'first question' }], source: { kind: 'user' } })) await waitForIdle(ctx1, a1) - const events1 = [...a1.session.snapshotEvents()] + const events1 = a1.session.snapshotEvents() const seqs1 = events1.map(e => e.seq) expect(seqs1).toEqual([...seqs1].sort((x, y) => x - y)) // contiguous await ctx1.fiber.dispose() diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 8adccaa4b5..cc079ca291 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -41,8 +41,8 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Build one assistant response containing the supplied tool calls. */ diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 364aa44a61..c2f11e3572 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -16,7 +16,7 @@ function userText(session: Session, text: string): void { /** From-scratch oracle: replay the log into a fresh session and derive. */ function scratch(session: Session): unknown { - return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), [...session.snapshotEvents()]).deriveMessages() + return Session.create(SessionId(`${session.id}-scratch-${session.seq}`), session.snapshotEvents()).deriveMessages() } describe('derived-message cache', () => { diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 4829d58a89..4eef9e7283 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -110,7 +110,7 @@ describe('Session properties', () => { it('replay-from-seed reproduces the derivation identically', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const replayed = Session.create(SessionId(`replay-${counter++}`), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId(`replay-${counter++}`), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // Every explicit replay grows by exactly one log-only boundary. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) @@ -121,8 +121,8 @@ describe('Session properties', () => { it('replaying a log that already ends in end-seed adds no further marker', () => { fc.assert(fc.property(logArb, (events) => { const original = build(events) - const once = Session.create(SessionId(`idem-a-${counter++}`), [...original.snapshotEvents()]) - const twice = Session.create(SessionId(`idem-b-${counter++}`), [...once.snapshotEvents()]) + const once = Session.create(SessionId(`idem-a-${counter++}`), original.snapshotEvents()) + const twice = Session.create(SessionId(`idem-b-${counter++}`), once.snapshotEvents()) // Lazy resume makes browsing a pickup, so this must not grow per open. expect(twice.snapshotEvents()).toEqual(once.snapshotEvents()) })) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 05a5398877..f592d8e1aa 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -132,7 +132,7 @@ describe('Session', () => { }, { surfaceOp: 'append' }) original.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const replayed = Session.create(SessionId('s3-replay'), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId('s3-replay'), original.snapshotEvents()) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) // The seed verbatim, plus the end-seed event the constructor appends. expect(replayed.snapshotEvents().slice(0, original.seq)).toEqual(original.snapshotEvents()) @@ -1141,7 +1141,7 @@ describe('SessionStore', () => { a.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) - const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.snapshotEvents()] }) + const forked = ctx.sessions.create(SessionId('fork'), { seed: a.snapshotEvents() }) expect(forked.deriveMessages()).toEqual(a.deriveMessages()) }) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 60ce0ebd54..3a92246a85 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -479,7 +479,7 @@ describe('SurfaceManager', () => { isError: false, }), }, { surfaceOp: 'append' }) - const replayed = Session.create(SessionId('replay'), [...original.snapshotEvents()]) + const replayed = Session.create(SessionId('replay'), original.snapshotEvents()) expect(replayed.surface.nodes).toEqual([1, 2, 4]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) }) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index ac422b481f..774d04674c 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -43,7 +43,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => expect(content).not.toContain('draft') // The log records real read/write/edit tool calls (not bash). - const calls = [...agent.session.snapshotEvents()].filter(e => e.type === 'tool/call').map(e => e.data.name) + const calls = agent.session.snapshotEvents().filter(e => e.type === 'tool/call').map(e => e.data.name) expect(calls).toContain('write') expect(calls).toContain('read') expect(calls).toContain('edit') diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index b324c85096..0ac97a7a0b 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ function reminders(agent: Agent): { text: string; source: unknown }[] { - return [...agent.session.snapshotEvents()] + return agent.session.snapshotEvents() .filter((e): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind !== 'user') .map(e => ({ text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'), @@ -340,7 +340,7 @@ describe('fold onto the downstream decision', () => { expect(found[1]!.source).toEqual(guardSource('probe', 2)) expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } }) // The block's feedback reached the tool result unchanged. - const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results.every(r => r.data.message.content[0].isError)).toBe(true) expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'nope' }]) }) @@ -364,7 +364,7 @@ describe('fold onto the downstream decision', () => { const found = reminders(agent) expect(found).toHaveLength(1) expect(found[0]!.text).toContain('repeating the exact same tool call') - const results = [...agent.session.snapshotEvents()].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') + const results = agent.session.snapshotEvents().filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result') expect(results[1]!.data.message.content[0].content).toEqual([{ type: 'text', text: 'replaced' }]) }) }) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index a402f8ae02..45aeca1050 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -12,7 +12,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') expect(ev?.type).toBe('hook/invoked') if (ev?.type === 'hook/invoked') { expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' }) @@ -25,7 +25,7 @@ describe('hook/* session events', () => { const session = Session.create(SessionId('s')) appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') + const ev = session.snapshotEvents().find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { expect('matcher' in ev.data).toBe(false) } @@ -37,7 +37,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'h1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) - const full = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const full = session.snapshotEvents().find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } @@ -48,7 +48,7 @@ describe('hook/* session events', () => { turn: 1, point: 'Stop', handlerId: 'h3', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) - const sparse = [...session2.snapshotEvents()].find(e => e.type === 'hook/result') + const sparse = session2.snapshotEvents().find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) @@ -63,7 +63,7 @@ describe('hook/* session events', () => { // An explicit decision wins over the continue:false fallback. appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) - const decisions = [...session.snapshotEvents()] + const decisions = session.snapshotEvents() .filter(e => e.type === 'hook/result') .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) @@ -75,7 +75,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'long', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') } @@ -87,7 +87,7 @@ describe('hook/* session events', () => { turn: 1, point: 'PreToolUse', handlerId: 'edge', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) - const ev = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const ev = session.snapshotEvents().find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } @@ -98,8 +98,8 @@ describe('hook/* session events', () => { appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' }) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) - const invoked = [...session.snapshotEvents()].find(e => e.type === 'hook/invoked') - const result = [...session.snapshotEvents()].find(e => e.type === 'hook/result') + const invoked = session.snapshotEvents().find(e => e.type === 'hook/invoked') + const result = session.snapshotEvents().find(e => e.type === 'hook/result') expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1') expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index b92900e01c..fb24106d0e 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -72,8 +72,8 @@ function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index a6e032d353..af39599dd2 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -54,7 +54,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 200de375ba..bdf476e0fe 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -56,7 +56,7 @@ async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Co function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 6aad28fff0..04217b1ee3 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -44,7 +44,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp function waitForIdle(_ctx: Context, agent: Agent): Promise { return agent.whenIdle() } -function events(agent: Agent): SessionEvent[] { return [...agent.session.snapshotEvents()] } +function events(agent: Agent): readonly SessionEvent[] { return agent.session.snapshotEvents() } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 403451a49a..e1dc3a13b5 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -288,7 +288,7 @@ describe('SessionTelemetryCoordinator adoption', () => { inject: ['sessions'], apply: (inner: Context) => void new SessionTelemetryCoordinator(inner, backend), }) - const child = ctx.sessions.prepare(SessionId('seeded'), { seed: [...parent.snapshotEvents()], meta: {} }) + const child = ctx.sessions.prepare(SessionId('seeded'), { seed: parent.snapshotEvents(), meta: {} }) child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) ctx.sessions.enter(child) ctx.sessions.announce(child) @@ -307,7 +307,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) donor.append('turn/start', { turn: 1 }) donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) - const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.snapshotEvents()], meta: {} }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: donor.snapshotEvents(), meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], @@ -335,7 +335,7 @@ describe('SessionTelemetryCoordinator adoption', () => { const parent = liveSession(ctx, 'stitch-parent') appendTurn(parent) const child = ctx.sessions.create(SessionId('stitch-child'), { - seed: [...parent.snapshotEvents()], + seed: parent.snapshotEvents(), meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, }) await ctx.plugin({ diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index ac54cc7c06..7923382bc8 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -60,13 +60,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { }) } -function events(agent: Agent): SessionEvent[] { - return [...agent.session.snapshotEvents()] +function events(agent: Agent): readonly SessionEvent[] { + return agent.session.snapshotEvents() } /** Find a session event by type, narrowed; throws when absent. */ function findEvent( - log: SessionEvent[], + log: readonly SessionEvent[], type: T, position: 'first' | 'last' = 'first', ): Extract { diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index 7baa1069fd..7bad2d991e 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -67,7 +67,7 @@ function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { /** Extra inputs the spawn and fork providers supply to the shared driver. */ export interface InProcessRunOptions { /** Completed-turn seed for fork, or undefined for a fresh spawn. */ - readonly seed?: SessionEvent[] + readonly seed?: readonly SessionEvent[] } /** Error used when cancellation wins before the child publication boundary. */ diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index a30107342e..e9e1a1dda2 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -141,7 +141,7 @@ describe('in-process policy inheritance', () => { const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'fork-blocked.txt') setSandboxMode(parent.session, 'workspace-write') - const seed = [...parent.session.snapshotEvents()] + const seed = parent.session.snapshotEvents() setSandboxMode(parent.session, 'read-only') script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index a4e4cb61f6..cadddf8814 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -201,7 +201,7 @@ describe('startInProcessRun', () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) await parent.whenIdle() - const seed = parent.session.snapshotEvents().slice() + const seed = parent.session.snapshotEvents() const run = await startInProcessRun(request(parent), { seed }) const result = await run.result expect(text(result.output)).toBe('child answer') diff --git a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts index ee2c79e1e1..8a17743341 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/spawn-in-process.e2e.ts @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( // The parent's log records the subagent tool/call + its result (not the // child's internal steps). - const events = [...parent.session.snapshotEvents()] + const events = parent.session.snapshotEvents() const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') expect(subagentCalls.length).toBeGreaterThan(0) }, 180_000) diff --git a/packages/subagent/subagent/src/descriptor-seed.ts b/packages/subagent/subagent/src/descriptor-seed.ts index 1b5c90bb07..4190395023 100644 --- a/packages/subagent/subagent/src/descriptor-seed.ts +++ b/packages/subagent/subagent/src/descriptor-seed.ts @@ -24,8 +24,8 @@ export function seedDescriptorTurn( childId: SessionId, seed: readonly SessionEvent[] | undefined, descriptor: SubagentDescriptorData, -): SessionEvent[] { +): readonly SessionEvent[] { const staged = Session.create(childId, seed) staged.append('subagent/descriptor', descriptor) - return [...staged.snapshotEvents()] + return staged.snapshotEvents() } diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index e3d2001554..c8f52e7c80 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -66,7 +66,7 @@ describe('todo snapshot invariants', () => { const session = ctx.sessions.create() session.append('turn/start', { turn: 1 }) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const before = [...session.snapshotEvents()] + const before = session.snapshotEvents() expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/) expect(session.snapshotEvents()).toEqual(before) From 47e6448e236ea887c18fed7b77f27c0a78935a51 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:17:21 +0800 Subject: [PATCH 03/38] perf(session): avoid unnecessary event snapshots --- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +-- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- .../context/agent-instructions/src/state.ts | 8 ++--- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../interaction/user-approval/src/index.ts | 34 ++++++------------- .../user-approval/tests/approval.spec.ts | 24 ++++++++----- .../src/workspace-access.ts | 3 -- 14 files changed, 43 insertions(+), 52 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4ca92ba463..f5f21346b9 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: 2ed8dd593e69b00d659ac71e96e856456a5e9185 -config-catalog.zh.md: dd22bea8e2f47179d9458cfdba1f74726fb78ae2 +config-catalog.md: 29c1d2c018f53c9bf9e3a4a1e78f8bc4d6d7247a +config-catalog.zh.md: c6f173718e86c3b4fcbedace292551532aec95a1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2ed8dd593e..29c1d2c018 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3203,7 +3203,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dd22bea8e2..c6f173718e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3205,7 +3205,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:142`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 08c524b34f..076760fb44 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: b3c7ee3d13d1098d560a99554491a9890a4083b5 -persistence-catalog.zh.md: 730d4b0db3752979955ecedfa94985c0f03066bd +persistence-catalog.md: 71c350b9d17ed7735e7b3bedbec6256301a531e9 +persistence-catalog.zh.md: 4b5f996276b9b56bd969ea8fdf2bf277e8b4da2e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b3c7ee3d13..71c350b9d1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -178,7 +178,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 730d4b0db3..4b5f996276 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -180,7 +180,7 @@ export type SessionEvent = { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 47c6c79f0a..65231608af 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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.md -session.md: c6c2395ba2535b1d180c82e63dc31a1ed6c1e087 -session.zh.md: df2bac57a4a847df62467ed772386eca24c02183 +session.md: 208820e006b148e2eb0f346dc29967171737569c +session.zh.md: 7eecf562595e92778017b954fb45dd86c6dcb9cf diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index c6c2395ba2..208820e006 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -626,7 +626,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df2bac57a4..7eecf56259 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -630,7 +630,7 @@ resolveAgent(sessionId: SessionId): Promise * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ -inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> /** * Read all visible Session rows without resuming an Agent. diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index 93e6bb3d10..fb25e262f9 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -137,13 +137,13 @@ function visibleInstructionChanges( agent: Agent, authorityMessages: readonly UserMessage[], ): Map { - const visibleSeqs = new Set(agent.session.surface.nodes) const visible = new Map() - for (const event of agent.session.snapshotEvents()) { - if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue + for (const seq of agent.session.surface.nodes) { + const event = agent.session.eventAt(seq) + if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) for (const change of changes) { - if (visibleSeqs.has(event.seq)) visible.set(change.scope, change) + visible.set(change.scope, change) } } for (const message of authorityMessages) { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0730e5f8b4..4e188f688c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1335,7 +1335,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the live Agent or the stable Session-domain failure.', }, { - signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }>', description: 'Inspect one attached or persisted Session without activating its Agent.', parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }], returns: 'the current attached state or persisted header and event prefix.', diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 93c4a605ab..1aec3ded27 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session/types' { * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the runtime-context snapshot and live switch notices). The LAST - * such event is the session's override ({@link effectiveApprovalPolicy}). + * such event is the session's override. * `source: 'delegation'` marks an override seeded into a child; an absent * source is a runtime switch. */ @@ -66,22 +66,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t /** Model-facing statement for an interactive policy that may still fail closed. */ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.' -/** - * The session's approval-policy override: the last `approval/policy` event in - * the log, or undefined when the session never switched (callers apply the - * plugin's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. - * @param events - session events in log order (other event types are skipped). - * @returns the policy of the last switch event, or undefined without one. - */ -export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index] as SessionEvent - if (event.type === 'approval/policy') return event.data.policy - } - return undefined -} - /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. @@ -89,9 +73,9 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv * commit/replay boundary, so a bare event appended between turns is * indistinguishable from a crash tail and silently dropped on reload. */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type +function hasOpenTurn(session: Session): boolean { + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const type = session.eventAt(seq)?.type if (type === 'turn/start') return true if (type === 'turn/end') return false } @@ -221,7 +205,7 @@ export class ApprovalService extends Service { */ async request(req: ApprovalRequest): Promise { const session = req.agent.session - if (!hasOpenTurn(session.snapshotEvents())) { + if (!hasOpenTurn(session)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' @@ -257,7 +241,11 @@ export class ApprovalService extends Service { * @returns the last logged policy, or `undefined` without one. */ overrideOf(session: Session): ApprovalPolicy | undefined { - return effectiveApprovalPolicy(session.snapshotEvents()) + for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + const event = session.eventAt(seq) + if (event?.type === 'approval/policy') return event.data.policy + } + return undefined } /** diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 868ba4f384..70e1abcf4e 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -7,22 +7,26 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService, { ApprovalOutcome, ApprovalRequest, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' /** * A minimal Agent stand-in — the service only reaches `agent.session.append` - * and folds `snapshotEvents()`. Seeded inside an open turn by default (request()'s + * and indexed log reads. Seeded inside an open turn by default (request()'s * turn-enclosure precondition); pass `seed` to stage idle/closed logs. * Returns the recorded audit appends alongside the fake. */ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } { const appended: Array<{ type: string; data: Record }> = [] + const events: Array<{ type: string; data?: Record }> = [...seed] const agent = { session: { - snapshotEvents: () => seed, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], append: (type: string, data: Record) => { - appended.push({ type, data }) - return { type, data } as unknown as SessionEvent + const event = { type, data } + events.push(event) + appended.push(event) + return event as unknown as SessionEvent }, }, } as unknown as Agent @@ -164,7 +168,8 @@ describe('ApprovalService.request', () => { const failure = new Error('append failed before log growth') const agent = { session: { - snapshotEvents: () => [{ type: 'turn/start' }], + seq: 1, + eventAt: () => ({ type: 'turn/start' }), append: () => { throw failure }, }, } as unknown as Agent @@ -365,11 +370,12 @@ describe('approval policy (the approval/policy fold)', () => { } it('folds to the last event, or undefined without one', () => { + const service = new ApprovalService(new Context(), {}) const { session } = sessionAgent('sess-fold') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBeUndefined() + expect(service.overrideOf(session)).toBeUndefined() setApprovalPolicy(session, 'never') setApprovalPolicy(session, 'ask') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('ask') + expect(service.overrideOf(session)).toBe('ask') expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } }) }) @@ -458,7 +464,7 @@ describe('approval policy (the approval/policy fold)', () => { ctx.approval.setPolicy(liveAgent, 'never') ctx.approval.setPolicy(liveAgent, 'never') - expect(effectiveApprovalPolicy(session.snapshotEvents())).toBe('never') + expect(ctx.approval.overrideOf(session)).toBe('never') expect(inject).toHaveBeenCalledOnce() expect(inject.mock.calls[0]?.[0]).toMatchObject({ content: [{ diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index 697adc5951..abda7f8b93 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -8,7 +8,6 @@ import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId, - type SessionEvent, type SessionHeader, type SessionId as SessionIdValue, } from '@deepseek-ai/dsh-session' @@ -24,7 +23,6 @@ import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader - readonly events: readonly SessionEvent[] /** The caller's own-session boundary fold (the `turnBoundary` projection). */ readonly boundary: TurnBoundaryProjection | undefined } @@ -66,7 +64,6 @@ function callerOf(exec: ToolRunContext, ctx: Context): Caller { return { id: agent.session.id, header: agent.session.header, - events: agent.session.snapshotEvents(), boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), } } From 32d681f0237c3672f1bd93b437a26b034af80ac0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:55:46 +0800 Subject: [PATCH 04/38] fix(subagent): preserve empty restored model selection Detect explicit empty seeds through the existing end-seed marker and remove the redundant Session.seeded API. --- ...08-24-user-authorized-subagent-model-routes.i18n.yaml | 4 ++-- .../2026-08-24-user-authorized-subagent-model-routes.md | 2 +- ...026-08-24-user-authorized-subagent-model-routes.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 ++-- docs/subsystems/session.md | 7 ------- docs/subsystems/session.zh.md | 7 ------- packages/core/session/README.i18n.yaml | 4 ++-- packages/core/session/README.md | 2 -- packages/core/session/README.zh.md | 2 -- packages/core/session/src/index.ts | 9 --------- packages/core/session/tests/session.spec.ts | 2 -- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/subagent/tool-subagent/README.i18n.yaml | 4 ++-- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 4 +++- .../tool-subagent/tests/model-selection-settings.spec.ts | 4 ++++ 17 files changed, 20 insertions(+), 43 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 2a8c4bbcb5..697f943349 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89 -2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48 +2026-08-24-user-authorized-subagent-model-routes.md: ef6f90d2cc1ec79e1381d75fd8cc25c568af3067 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 23519bfc6619718e1cbf98dbad65eccd90353c0a diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index fce0026f65..ef6f90d2cc 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -12,7 +12,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. -A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session. The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 2cdf42dd33..23519bfc66 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -12,7 +12,7 @@ Status: implemented Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 -设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 65231608af..cbc8c58b24 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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.md -session.md: 208820e006b148e2eb0f346dc29967171737569c -session.zh.md: 7eecf562595e92778017b954fb45dd86c6dcb9cf +session.md: 9e19596f702f5ca7f219be46fb122ec9f00659af +session.zh.md: 238638f868047fba31c1891ca678a29a84d5f834 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 208820e006..9e19596f70 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -379,13 +379,6 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7eecf56259..238638f868 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -381,13 +381,6 @@ declare class Session { * holds an ordinary published write. */ readonly firstLiveSeq: number; - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean; /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 20d1182786..74d7410759 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/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/core/session/README.md -README.md: f490c53c846c00f8d0c91b7ad7fae3f688522a31 -README.zh.md: 0f32bc5ba04e6da2bc8907280ef2c8ea9dc81b9a +README.md: b583941ef8f7fb0760070b2e2168a6e9806ba9c2 +README.zh.md: 6a1f5d6993af483c027b715d1e3bfdb3b06f7ab4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index f490c53c84..b583941ef8 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -53,8 +53,6 @@ Surface events (`user/message`, `assistant/message`, `tool/result`) must declare `session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. -`session.seeded` distinguishes construction with a replay, fork, or restored seed—including an explicitly empty seed—from a fresh session without scanning for `session/end-seed`. - ### Fork a session `ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 0f32bc5ba0..6a1f5d6993 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -53,8 +53,6 @@ session.deriveMessages() // the derived model history `session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 -`session.seeded` 可区分以 replay、fork 或恢复 seed(包括显式空 seed)构造的会话与全新会话,无需扫描 `session/end-seed`。 - ### 派生会话的 fork `ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 3d934b0884..cc4fc60cd3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -469,14 +469,6 @@ export class Session { */ readonly firstLiveSeq: number - /** - * Whether construction received a replay, fork, or restored seed. - * Every seeded lifecycle has a `session/end-seed` marker after construction; - * this field distinguishes an explicitly empty seed from a fresh session - * without scanning the log. - */ - readonly seeded: boolean - /** * Create a detached session by validating and snapshotting borrowed seed * events and storage metadata. @@ -543,7 +535,6 @@ export class Session { } } this.firstLiveSeq = this.log.length - this.seeded = seed !== undefined this.header = restoredHeader ?? snapshotSessionHeader(id, header) // Appended here so the marker is already in `events` when a backend // captures the creation seed: no load-time write. Re-marking is skipped diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f592d8e1aa..cbce08d5ff 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -142,11 +142,9 @@ describe('Session', () => { it('marks an explicitly empty seed without marking a fresh session', () => { const fresh = Session.create(SessionId('fresh-empty')) - expect(fresh.seeded).toBe(false) expect(fresh.snapshotEvents()).toEqual([]) const resumed = Session.create(SessionId('resumed-empty'), []) - expect(resumed.seeded).toBe(true) expect(resumed.firstLiveSeq).toBe(0) expect(resumed.snapshotEvents()).toMatchObject([ { type: 'session/end-seed', seq: 0, data: {} }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4e188f688c..b629dee500 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4740,7 +4740,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n readonly seeded: boolean;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAddress', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 660bf0ba4d..bfe7d2aa8b 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md -README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 -README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b +README.md: 8dabfe42e2aa200294820dfb0c3bc366134d6ac9 +README.zh.md: b3efaae8355d998cb0f47735fac98a368c16277b diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5520e98ddc..8dabfe42e2 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -64,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du ### Selecting a child LLM -Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. +Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each fresh top-level Session is composed. A restored Session without a recorded policy remains disabled, including an explicitly empty restore. When enabled, the non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 069eca4f8c..b3efaae835 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 +设置 `modelSelectionSettings: true`,即可在组合每个全新顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。没有已记录策略的恢复 Session 会保持禁用,包括显式为空的恢复。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 387c948d53..8f693c58b2 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -619,6 +619,8 @@ export function apply(ctx: Context, config: Config): void { } const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + const freshSession = agent.session.firstLiveSeq === 0 + && agent.session.eventAt(0)?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' @@ -629,7 +631,7 @@ export function apply(ctx: Context, config: Config): void { allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(ctx.sessionProjections, parent.session) - } else if (agent.session.firstLiveSeq === 0) { + } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 1aa93df37c..6926b64888 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -314,6 +314,10 @@ describe('SubagentModelSelectionConfig', () => { enabled: true, allowedModels: ALLOWED_MODELS, }) + const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) + expect(selectable(ctx, resumedEmpty)).toBe(false) + expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() From 1b6f9a1b5182c9f482a73863b8fea0a6656fbc0a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 14:58:11 +0800 Subject: [PATCH 05/38] refactor(session-telemetry): simplify capture replay --- packages/session/session-telemetry/src/coordinator.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 8725888d7b..b558cedf8a 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -137,14 +137,9 @@ export class SessionTelemetryCoordinator { */ captureSession(session: Session, throughSeq?: number): void { const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 - const length = session.seq // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. - for (let seq = 0; seq < length; seq++) { - const event = session.eventAt(seq) - if (event === undefined) { - throw new Error(`session telemetry capture cannot read seq ${String(seq)} below captured length ${String(length)}`) - } + for (const event of session.snapshotEvents()) { if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { if (event.seq <= cursor) this.track(session, event) From 4096b0b408ed6d1380facbfdb34c9f498c58eab0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 15:02:12 +0800 Subject: [PATCH 06/38] refactor(time-context): scan turn messages in reverse --- packages/context/time-context/src/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 6649655709..87f09e2241 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -77,15 +77,15 @@ function formatDuration(elapsedMs: number): string { /** Collect already-entered and proposed user messages belonging to one open turn. */ function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { - const events = agent.session.snapshotEvents() - const start = events.findLastIndex( - event => event.type === 'turn/start' && event.data.turn === turn, - ) - const entered = start < 0 - ? [] - : events.slice(start + 1) - .flatMap(event => event.type === 'user/message' ? [event.data] : []) - return [...entered, ...proposed] + const entered: UserMessage[] = [] + for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { + const event = agent.session.eventAt(seq) + if (event?.type === 'turn/start' && event.data.turn === turn) { + return [...entered.reverse(), ...proposed] + } + if (event?.type === 'user/message') entered.push(event.data) + } + return [...proposed] } function renderText( 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 07/38] 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 febafc7ee0519b855d26db28c2d48371c2d3ba2e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 31 Aug 2026 17:43:17 +0800 Subject: [PATCH 08/38] fix(test): cover indexed session reads --- packages/acp/acp/tests/approval.spec.ts | 5 ++++- packages/bundle/headless/tests/headless.spec.ts | 15 +++++++++++++++ .../agent-loop/tests/contract-regressions.spec.ts | 2 +- packages/fs/tool-fs/tests/tools.spec.ts | 10 ++++++++-- .../session-log-deepseek/tests/upload.spec.ts | 11 +++++++++++ packages/shell/tool-bash/tests/tools.spec.ts | 11 +++++++---- packages/shell/tool-pwsh/tests/tools.spec.ts | 15 ++++++++++++--- .../skill/tool-skill/tests/tool-skill.spec.ts | 12 ++++++++++++ 8 files changed, 70 insertions(+), 11 deletions(-) diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index 7ac71f1ea0..25fc940a06 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -68,10 +68,13 @@ describe('ACP machine permission policy', () => { it('delegates a same-id foreign agent', async () => { harness = await makeBridgeHarness() const request = await ownedRequest() + const events = [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }] const foreign = { session: { id: request.agent.session.id, - snapshotEvents: () => [{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } }], + seq: events.length, + eventAt: (seq: number) => events[seq], + snapshotEvents: () => events, append: () => ({}), }, } as unknown as Agent diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index fb86ff8387..bc788682bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -310,6 +310,21 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('fails when an event below the captured Session length cannot be read', async () => { + const test = await bench({ + afterPrompt(session, message) { + appendTurn(session, 1, message, 'unreachable', true) + Object.defineProperty(session, 'eventAt', { value: () => undefined }) + }, + }) + expect(await test.run()).toMatchObject({ + code: 1, + out: '', + err: 'dsh: headless summary cannot read seq 0 below captured length 7\n', + }) + await test.ctx.fiber.dispose() + }) + it('reports a direct Agent creation failure', async () => { const ctx = new Context() let err = '' diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 4774099847..ab338d3b01 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -540,7 +540,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { send(agent, 'go') await waitForIdle(ctx, agent) - const events = agent.session.events + const events = agent.session.snapshotEvents() const claims = events.flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' && event.data.outcome !== 'canceled' diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ecd258f24c..bb14264f6d 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -806,12 +806,18 @@ describe('sandbox escalation API (write/edit)', () => { /** A fake agent whose session records appends (the approval audit trail), mid-turn, carrying the given events for the fold. */ function escalationAgent(events: Array<{ type: string; data?: Record }> = []): object { + const log = [ + { type: 'turn/start', data: { turn: 1 }, seq: 0 }, + ...events.map((event, index) => ({ ...event, seq: index + 1 })), + ] return { id: 'agent-fs-esc', session: { header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' }, - snapshotEvents: () => [{ type: 'turn/start', data: { turn: 1 } }, ...events], - append: (type: string, data: Record) => { events.push({ type, data }) }, + get seq() { return log.length }, + eventAt: (seq: number) => log[seq], + snapshotEvents: () => log, + append: (type: string, data: Record) => { log.push({ type, data, seq: log.length }) }, }, } } diff --git a/packages/session/session-log-deepseek/tests/upload.spec.ts b/packages/session/session-log-deepseek/tests/upload.spec.ts index 560851a4d8..38526420e0 100644 --- a/packages/session/session-log-deepseek/tests/upload.spec.ts +++ b/packages/session/session-log-deepseek/tests/upload.spec.ts @@ -133,6 +133,17 @@ describe('incremental DeepSeek session-log upload', () => { expect(reads).toBe(2) }) + it('rejects a missing event below the captured Session length', () => { + const session = { + id: SessionId('missing-event'), + seq: 1, + eventAt: () => undefined, + } as unknown as Session + + expect(() => SessionLogDeepSeek.acceptedThrough(session)) + .toThrow('session-log-deepseek: missing event 0 below captured length 1') + }) + it('omits the field for direct or stale requests and uploads the prior acceptance marker next', async () => { const { ctx, session } = await harness('edges') await expect(ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL })) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 41ff321721..fd6bc51b16 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -205,8 +205,8 @@ function sandboxAgent( ctx?: Context, onAppend?: (type: string) => void, ): Agent { - const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start', data: { turn: 1 } }] - if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } }) + const events: Array<{ type: string; data?: Record; seq: number }> = [{ type: 'turn/start', seq: 0, data: { turn: 1 } }] + if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: events.length, data: { mode } }) const id = SessionId('sandbox-session') return { id, @@ -214,9 +214,11 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], snapshotEvents: () => events, append: (type: string, data: Record) => { - const event = { type, data } + const event = { type, data, seq: events.length } events.push(event) onAppend?.(type) return event @@ -625,9 +627,10 @@ describe('sandbox escalation through the generic task producer', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, + seq: malformed.session.seq, }) expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 67ac413252..c50af1a02f 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -247,9 +247,11 @@ function sandboxAgent( session: { id, header: { version: 0, id, createdAt: 0 }, + get seq() { return events.length }, + eventAt: (seq: number) => events[seq], snapshotEvents: () => events, append: (type: string, data: Record) => { - const event = { type, data } + const event = { type, data, seq: events.length } events.push(event) onAppend?.(type) return event @@ -270,7 +272,13 @@ function registerFakeAgent(ctx: Context, sessionId: string): Agent { const agent = { id, ctx: scopeFiber.ctx, - session: { id, header: { version: 0, id, createdAt: 0 }, snapshotEvents: () => [] }, + session: { + id, + header: { version: 0, id, createdAt: 0 }, + seq: 0, + eventAt: () => undefined, + snapshotEvents: () => [], + }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -602,9 +610,10 @@ describe('sandbox escalation through ctx.approval', () => { expect(prompted).not.toHaveBeenCalled() const malformed = sandboxAgent() - ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string } }>).push({ + ;(malformed.session.snapshotEvents() as unknown as Array<{ type: string; data: { mode: string }; seq: number }>).push({ type: 'sandbox/mode', data: { mode: 'unknown-mode' }, + seq: malformed.session.seq, }) expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider') }) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index b665d2e1bb..7a488ba2e0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -585,6 +585,18 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(published[0]?.data.content)).toContain('live-skill') }) + it('rejects a missing event below the current Session length', async () => { + const home = await tempDir('tool-catalog-missing-event') + const ctx = await setup(home) + const session = Session.create(SessionId('catalog-missing-event')) + const agent = sessionAgent(session) + openMessageTurn(session) + Object.defineProperty(session, 'eventAt', { value: () => undefined }) + + await expect(fireStep(ctx, agent, 1, 1)) + .rejects.toThrow('skill catalog cannot read seq 1 below the current Session length') + }) + it('re-establishes the current catalog after compaction hides its durable message', async () => { const home = await tempDir('tool-catalog-compaction') const ctx = await setup(home) 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 09/38] 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 10/38] 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 11/38] 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 12/38] 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 13/38] 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 20/38] 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 21/38] 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 22/38] 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 23/38] 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 24/38] 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 25/38] 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 26/38] 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 27/38] 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 28/38] 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 29/38] 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 30/38] 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 31/38] 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 32/38] 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 33/38] 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() From faa61ada74b7fb937f802e353057254b173f7e80 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 21:53:17 +0800 Subject: [PATCH 34/38] perf(ui-primitives): defer offscreen syntax highlighting --- ...rt-activated-syntax-highlighting.i18n.yaml | 6 + ...-viewport-activated-syntax-highlighting.md | 41 +++++ ...ewport-activated-syntax-highlighting.zh.md | 41 +++++ .../client/ui-primitives/src/ReadBlock.tsx | 12 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 18 ++- .../ui-primitives/src/markdown/highlight.ts | 9 ++ .../src/markdown/useViewportHighlighting.ts | 72 +++++++++ .../tests/highlight-viewport.client.spec.tsx | 147 ++++++++++++++++++ 8 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md create mode 100644 .agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md create mode 100644 packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts create mode 100644 packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml new file mode 100644 index 0000000000..d8166a7a02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md +2026-08-31-viewport-activated-syntax-highlighting.md: 050c4e50240f9e1d1eb4f2708df6bc93c3a1faf1 +2026-08-31-viewport-activated-syntax-highlighting.zh.md: abd02161ae00d8bdd31863826565175e1d8fddf9 diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md new file mode 100644 index 0000000000..050c4e5024 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.md @@ -0,0 +1,41 @@ +# Agent Note: Viewport-activated syntax highlighting + +Status: implemented + +English | [中文](2026-08-31-viewport-activated-syntax-highlighting.zh.md) + +## Problem + +A long conversation mounts code fences and read cards far outside the visible viewport. Eager highlighting tokenizes every supported block and creates every token span during that mount, so main-thread work and DOM size scale with the whole rendered history rather than the code the reader can see. The [Shiki selection](../process/2026-07-26-web-syntax-highlighting-shiki.md) and [incremental streaming highlighter](../feature/2026-08-20-web-streaming-fence-highlight.md) bound initialization and repeated prefix work, but neither avoids the first per-block tokenization for unseen history. + +## Decision + +`useViewportHighlighting` owns one shared `IntersectionObserver` for syntax-highlightable `CodeBlock` and `ReadBlock` instances. A supported block renders its existing plain-text arm until its root first intersects the viewport. An absent or unsupported language never registers with the observer. A browser without `IntersectionObserver` activates highlighting after mount so the capability still works. + +The first intersecting entry removes its target from the observer and activates that component for the rest of its lifetime. Leaving the viewport never returns it to plain text. This one-way transition avoids repeated tokenization, token-DOM construction, and visual churn while scrolling. The shared observer disconnects when no inactive registered blocks remain. + +`CodeBlock` gates both settled `highlightToHtml` calls and streaming `StreamingHighlightSession` creation. It starts an activated stream from the current accumulated source, then retains the existing incremental tokenizer and React line caches. `ReadBlock` gates `highlightLines` while retaining its line rows and gutter. The plain and highlighted arms keep the same source text, code font, padding, wrapping, and line height; Shiki's color, bold, italic, and underline token styles remain unchanged. + +The module-level Shiki singleton warm-up remains eager. Viewport activation defers code-block content tokenization and token-span construction, not the fixed boot-grammar warm-up or the plain content DOM. + +## Testing + +The focused jsdom test replaces the process-global `IntersectionObserver`, mounts several code surfaces, and proves that non-intersecting and unsupported blocks remain plain, intersecting blocks share one observer, leaving the viewport does not remove highlighting, and an activated block continues to highlight changed source. It also covers read-card activation and observer disposal. Each test restores the global and unmounts every component, so the module-level registry cannot leak registrations into another case. + +Existing component tests run without `IntersectionObserver` and therefore cover the immediate fallback together with the established Shiki output, font styles, streaming caches, and plain-language behavior. Browser geometry is not measured by this unit suite; geometry stability relies on the unchanged shared typography and box styles of the plain and highlighted arms. + +## Alternatives considered + +**Deactivate highlighting when a block leaves the viewport.** This can reclaim token DOM from blocks already viewed, but scrolling repeatedly rebuilds the same token tree, discards streaming caches, and changes visible presentation at both viewport edges. One-way activation pays the cost at most once per mounted block. + +**Drop bold and italic token styles to make every token use identical font metrics.** This weakens syntax presentation, especially for highlighted Markdown, and is unnecessary for the chosen lifecycle: both render arms already use the same code font and fixed line height. Shiki's existing token styles remain intact. + +**Pin a measured pixel height during activation.** A fixed measurement becomes stale when a streaming fence grows or responsive wrapping changes, and can clip content or introduce an inner vertical scrollbar. The plain arm stays in normal flow instead of adding measurement state. + +**Virtualize complete code blocks or retain only a token window.** This can also bound DOM after a reader has visited every block, but it changes selection, copy, scroll anchoring, and streaming-cache ownership. Viewport activation removes unseen work without changing those behaviors. + +## Consequences + +Supported code that is never viewed incurs no content tokenization and creates no token spans. The first viewport intersection pays the normal synchronous highlight cost; a lazily imported grammar may keep the block plain until its existing load notification arrives. Activated blocks retain their highlighted DOM when scrolled away, so memory use grows with blocks the reader has visited rather than shrinking with the current viewport. + +The optimization is local to presentation. Markdown parsing, Shiki grammar selection and styling, stream-tail tokenization, copy text, and settled output remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md new file mode 100644 index 0000000000..abd02161ae --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-viewport-activated-syntax-highlighting.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 视口激活的语法高亮 + +Status: implemented + +[English](2026-08-31-viewport-activated-syntax-highlighting.md) | 中文 + +## 问题 + +长对话会挂载远在可见视口之外的代码围栏和读取卡片。预先高亮会在挂载时 tokenize 每个受支持的块并创建全部 token span,使主线程工作量和 DOM 大小随完整渲染历史增长,而不是随读者能看到的代码增长。[Shiki 选型](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md)与[流式增量高亮](../feature/2026-08-20-web-streaming-fence-highlight.zh.md)分别约束初始化成本和重复处理前缀的成本,但都无法避免不可见历史首次发生的逐块 tokenize。 + +## 决策 + +`useViewportHighlighting` 为可进行语法高亮的 `CodeBlock` 和 `ReadBlock` 实例持有一个共享的 `IntersectionObserver`。受支持的块在根元素首次与视口相交之前渲染既有的纯文本臂。语言缺失或不受支持时不会向 observer 注册。浏览器不提供 `IntersectionObserver` 时会在挂载后激活高亮,使该能力仍然可用。 + +首个相交条目会从 observer 中移除自己的目标,并在该组件余下的生命周期里保持激活。离开视口不会恢复为纯文本。这种单向转换避免滚动时反复 tokenize、创建 token DOM 和发生视觉切换。不存在尚未激活的注册块时,共享 observer 会断开。 + +`CodeBlock` 同时控制定稿态的 `highlightToHtml` 调用和流式 `StreamingHighlightSession` 的创建。流式块激活时从当前累积源码开始,随后保留既有的增量 tokenizer 与 React 行缓存。`ReadBlock` 控制 `highlightLines`,同时保留其行和行号槽。纯文本臂与高亮臂使用相同的源码文本、代码字体、内边距、换行规则和行高;Shiki 现有的 token 颜色、粗体、斜体和下划线样式保持不变。 + +模块级 Shiki 单例仍然预先预热。视口激活延迟的是代码块内容的 tokenize 与 token span 创建,不是固定的启动语法预热或纯文本内容 DOM。 + +## Testing + +聚焦的 jsdom 测试替换进程全局 `IntersectionObserver`,挂载多个代码表面,并证明未相交和不受支持的块保持纯文本、相交块共用一个 observer、离开视口不会移除高亮,且已激活的块会继续高亮变化后的源码。测试还覆盖读取卡片激活和 observer 释放。每个测试都会恢复全局值并卸载全部组件,因此模块级注册表不会把注册项泄漏到其他用例。 + +既有组件测试在没有 `IntersectionObserver` 的环境中运行,因此同时覆盖立即回退路径,以及既有 Shiki 输出、字体样式、流式缓存和纯文本语言行为。该单元测试套件不测量浏览器几何尺寸;几何稳定性依赖纯文本臂与高亮臂不变的共享字体排印和盒模型样式。 + +## 曾考虑的替代方案 + +**块离开视口时停用高亮。** 这可以回收已经看过的块所占用的 token DOM,但滚动会反复重建同一棵 token 树、丢弃流式缓存,并在视口两端改变可见呈现。单向激活使每个已挂载块最多支付一次成本。 + +**移除粗体和斜体 token 样式,使所有 token 使用完全相同的字体度量。** 这会削弱语法呈现,尤其影响高亮后的 Markdown;所选生命周期也不需要这一取舍,因为两个渲染臂已经使用相同的代码字体和固定行高。Shiki 现有 token 样式保持不变。 + +**在激活时锁定测量得到的像素高度。** 当流式围栏增长或响应式换行变化时,固定测量会陈旧,并可能裁切内容或引入内部纵向滚动条。纯文本臂继续处于正常文档流中,不增加测量状态。 + +**虚拟化完整代码块,或仅保留一个 token 窗口。** 这也能在读者访问所有块之后限制 DOM,但会改变文本选择、复制、滚动锚定与流式缓存的归属。视口激活消除不可见工作,同时不改变这些行为。 + +## 后果 + +从未进入视口的受支持代码不会发生内容 tokenize,也不会创建 token span。首次与视口相交时支付普通的同步高亮成本;若语法采用懒加载,代码块可能继续保持纯文本,直到既有的加载通知到达。已激活的块滚出视口后会保留高亮 DOM,因此内存占用随读者访问过的块增长,而不会随当前视口缩减。 + +该优化仅作用于呈现层。Markdown 解析、Shiki 语法选择与样式、流式尾行 tokenize、复制文本和定稿输出均保持不变。 diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx index 979af09da9..6c1b2eaec9 100644 --- a/packages/client/ui-primitives/src/ReadBlock.tsx +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { FoldToggle } from './FoldToggle.tsx' import { writeClipboard } from './clipboard.ts' @@ -8,6 +8,7 @@ import { subscribeGrammarLoaded, type HighlightSpan, } from './markdown/highlight.ts' +import { useViewportHighlighting } from './markdown/useViewportHighlighting.ts' import css from './ReadBlock.module.css' /** @@ -72,6 +73,8 @@ export function ReadBlock({ maxLines = DEFAULT_READ_MAX_LINES, className, }: ReadBlockProps) { + const rootRef = useRef(null) + const highlighting = useViewportHighlighting(rootRef, lang) // Whole-window highlighting preserves multiline grammar context; copy uses // the same text without gutter or banner chrome. const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines]) @@ -79,7 +82,10 @@ export function ReadBlock({ // 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 highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded]) + const highlighted = useMemo( + () => highlighting ? highlightLines(raw, lang) : undefined, + [highlighting, raw, lang, loaded], + ) const [expanded, setExpanded] = useState(false) const [copied, setCopied] = useState(false) @@ -114,7 +120,7 @@ export function ReadBlock({ [line, highlighted?.[index]]) return ( -
+
{label ?? ''}
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 2a42092020..5ba93069dd 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -6,6 +6,7 @@ import { StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded, } from './highlight.ts' import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts' +import { useViewportHighlighting } from './useViewportHighlighting.ts' import css from './CodeBlock.module.css' export interface CodeBlockProps { @@ -58,6 +59,8 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code + const rootRef = useRef(null) + const highlighting = useViewportHighlighting(rootRef, lang) // 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. @@ -78,6 +81,12 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL } | null>(null) const settledRef = useRef(false) const streamedBody = useMemo(() => { + if (!highlighting) { + sessionRef.current = null + lineCacheRef.current = null + settledRef.current = false + return undefined + } if (streaming !== true) { const previous = lineCacheRef.current if (previous !== null && previous.code === trimmed && previous.lang === lang) { @@ -123,12 +132,13 @@ export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedL code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body, } return body - }, [streaming, trimmed, lang, loaded]) + }, [streaming, highlighting, trimmed, lang, loaded]) const html = useMemo( - () => (streaming !== true && streamedBody === undefined ? highlightToHtml(trimmed, lang) : undefined), - [streaming, streamedBody, trimmed, lang, loaded], + () => (highlighting && streaming !== true && streamedBody === undefined + ? highlightToHtml(trimmed, lang) + : undefined), + [streaming, highlighting, streamedBody, trimmed, lang, loaded], ) - const rootRef = useRef(null) const [copied, setCopied] = useState(false) const onCopy = useCallback(() => { diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index d97d8d6c7f..889ccf9325 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -132,6 +132,15 @@ const LANG_ALIASES = new Map([ ['lua', 'lua'], ]) +/** + * Whether a language hint can use the shared syntax highlighter. + * @param lang - Language hint from a code surface. + * @returns Whether the hint resolves to a supported grammar. + */ +export function supportsHighlighting(lang: string | undefined): boolean { + return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase()) +} + /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ const cssVariablesTheme = createCssVariablesTheme({ name: 'css-variables', diff --git a/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts new file mode 100644 index 0000000000..2f4114ce71 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/useViewportHighlighting.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from 'react' +import type { RefObject } from 'react' +import { supportsHighlighting } from './highlight.ts' + +const noop = (): void => {} + +/** One document-wide observer; activated elements leave it permanently. */ +class HighlightViewport { + private observer: IntersectionObserver | undefined + private readonly activators = new Map void>() + + observe(element: Element, activate: () => void): () => void { + if (typeof IntersectionObserver === 'undefined') { + activate() + return noop + } + this.observer ??= new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue + const current = this.activators.get(entry.target) + /* v8 ignore next -- the observer reports only elements still registered with it. */ + if (current === undefined) continue + this.activators.delete(entry.target) + this.observer?.unobserve(entry.target) + current() + } + this.releaseEmptyObserver() + }) + this.activators.set(element, activate) + this.observer.observe(element) + return () => { + this.activators.delete(element) + this.observer?.unobserve(element) + this.releaseEmptyObserver() + } + } + + private releaseEmptyObserver(): void { + if (this.activators.size > 0) return + this.observer?.disconnect() + this.observer = undefined + } +} + +const highlightViewport = new HighlightViewport() + +/** + * Activate one supported code surface when it first intersects the viewport. + * Activation lasts for the component lifetime; browsers without + * IntersectionObserver activate immediately. + * @param target - Code surface whose plain rendering reserves its geometry. + * @param lang - Optional language hint. + * @returns Whether this component may build highlighted output. + */ +export function useViewportHighlighting( + target: RefObject, + lang: string | undefined, +): boolean { + const supported = supportsHighlighting(lang) + const [activated, setActivated] = useState(false) + const activate = useCallback(() => { setActivated(true) }, []) + + useEffect(() => { + if (activated || !supported) return + const element = target.current + /* v8 ignore next -- React attaches the host ref before running effects. */ + if (element === null) return + return highlightViewport.observe(element, activate) + }, [activate, activated, supported, target]) + + return activated && supported +} diff --git a/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx new file mode 100644 index 0000000000..6dfdb1bd3a --- /dev/null +++ b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ReadBlock } from '../src/ReadBlock.tsx' +import { CodeBlock } from '../src/markdown/CodeBlock.tsx' +import { markdownLabels, readBlockLabels } from './labels.client.ts' + +class IntersectionObserverStub { + static instances: IntersectionObserverStub[] = [] + + readonly observed = new Set() + readonly unobserved = new Set() + disconnected = false + + constructor(private readonly callback: IntersectionObserverCallback) { + IntersectionObserverStub.instances.push(this) + } + + observe(element: Element): void { + this.observed.add(element) + } + + unobserve(element: Element): void { + this.observed.delete(element) + this.unobserved.add(element) + } + + disconnect(): void { + this.disconnected = true + this.observed.clear() + } + + takeRecords(): IntersectionObserverEntry[] { + return [] + } + + intersect(element: Element, isIntersecting: boolean): void { + this.callback( + [{ target: element, isIntersecting } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ) + } +} + +beforeEach(() => { + IntersectionObserverStub.instances = [] + vi.stubGlobal('IntersectionObserver', IntersectionObserverStub) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('viewport-activated syntax highlighting', () => { + it('keeps offscreen blocks plain and permanently activates only intersecting blocks', async () => { + const view = render( + <> + + + + , + ) + const blocks = [...view.container.querySelectorAll('.md-code-block')] + expect(blocks).toHaveLength(3) + expect(IntersectionObserverStub.instances).toHaveLength(1) + const observer = IntersectionObserverStub.instances[0]! + expect(observer.observed.size).toBe(3) + expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0) + + act(() => { observer.intersect(blocks[0]!, false) }) + expect(view.container.querySelectorAll('pre.shiki')).toHaveLength(0) + + act(() => { + observer.intersect(blocks[0]!, true) + observer.intersect(blocks[1]!, true) + }) + await waitFor(() => { + expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull() + expect(blocks[1]!.querySelector('pre.shiki')).not.toBeNull() + }) + expect(blocks[2]!.querySelector('pre.shiki')).toBeNull() + expect(observer.unobserved.has(blocks[0]!)).toBe(true) + + act(() => { observer.intersect(blocks[0]!, false) }) + expect(blocks[0]!.querySelector('pre.shiki')).not.toBeNull() + + view.rerender( + <> + + + + , + ) + expect(blocks[0]!.querySelector('pre.shiki')?.textContent).toBe('const first = 10') + + act(() => { observer.intersect(blocks[2]!, true) }) + await waitFor(() => { expect(blocks[2]!.querySelector('pre.shiki')).not.toBeNull() }) + expect(observer.disconnected).toBe(true) + }) + + it('does not observe an unsupported language', () => { + const view = render( + , + ) + expect(view.container.querySelector('pre.shiki')).toBeNull() + expect(IntersectionObserverStub.instances).toHaveLength(0) + }) + + it('releases the shared observer when the last pending block unmounts', () => { + const view = render() + const block = view.container.querySelector('.md-code-block')! + const observer = IntersectionObserverStub.instances[0]! + + view.unmount() + + expect(observer.unobserved.has(block)).toBe(true) + expect(observer.disconnected).toBe(true) + }) + + it('highlights immediately when IntersectionObserver is unavailable', () => { + vi.stubGlobal('IntersectionObserver', undefined) + const view = render() + expect(view.container.querySelector('pre.shiki')).not.toBeNull() + }) + + it('keeps a read card plain until that card intersects', async () => { + const view = render( + , + ) + const block = view.container.querySelector('[data-read]')! + expect(block.querySelectorAll('[class^="_content_"] span')).toHaveLength(0) + const observer = IntersectionObserverStub.instances[0]! + + act(() => { observer.intersect(block, true) }) + await waitFor(() => { + expect(block.querySelectorAll('[class^="_content_"] span[style]').length).toBeGreaterThan(1) + }) + }) +}) From 07be260245454c7dc84c44ef9b092ab81df6b5e3 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 31 Aug 2026 22:11:39 +0800 Subject: [PATCH 35/38] test(ui-primitives): cover lazy viewport highlighting --- .../tests/highlight-viewport.client.spec.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx index 6dfdb1bd3a..5c7e8816e8 100644 --- a/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx +++ b/packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx @@ -125,6 +125,19 @@ describe('viewport-activated syntax highlighting', () => { expect(view.container.querySelector('pre.shiki')).not.toBeNull() }) + it('keeps an intersecting streaming block plain until its lazy grammar loads', async () => { + const view = render( + , + ) + const block = view.container.querySelector('.md-code-block')! + const observer = IntersectionObserverStub.instances[0]! + + act(() => { observer.intersect(block, true) }) + expect(block.querySelector('pre.shiki')).toBeNull() + + await waitFor(() => { expect(block.querySelector('pre.shiki')).not.toBeNull() }, { timeout: 5_000 }) + }) + it('keeps a read card plain until that card intersects', async () => { const view = render( Date: Mon, 31 Aug 2026 22:14:43 +0800 Subject: [PATCH 36/38] fix(connection): tolerate stalled hosts --- packages/api/gateway/src/stream-server.ts | 19 ++++++---- .../gateway/tests/stream-server.host.spec.ts | 35 +++++++++++++++++-- .../connection/src/client/connection.ts | 7 ++-- .../tests/connection.client.spec.ts | 10 ++++-- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 9f0d5d4cab..3a36e97ecd 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -19,11 +19,13 @@ export type RemoteStreamOpener = ( /** Convert an invocation or carrier failure to a stable wire value. */ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure +const MAX_MISSED_HEARTBEATS = 2 + /** Own the no-server WebSocket acceptor and every active logical stream. */ export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() - private readonly heartbeatAlive = new WeakMap() + private readonly missedHeartbeats = new WeakMap() private heartbeatTimer: NodeJS.Timeout | undefined /** @@ -45,8 +47,8 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { - this.heartbeatAlive.set(websocket, true) - websocket.on('pong', () => { this.heartbeatAlive.set(websocket, true) }) + this.missedHeartbeats.set(websocket, 0) + websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() @@ -75,11 +77,16 @@ export class RemoteStreamMuxServer { this.heartbeatTimer = setInterval(() => { for (const socket of this.server.clients) { if (socket.readyState !== WebSocket.OPEN) continue - if (this.heartbeatAlive.get(socket) === false) { - socket.terminate() + const missed = this.missedHeartbeats.get(socket) as number + if (missed >= MAX_MISSED_HEARTBEATS) { + setImmediate(() => { + if ((this.missedHeartbeats.get(socket) as number) >= MAX_MISSED_HEARTBEATS) { + socket.terminate() + } + }) continue } - this.heartbeatAlive.set(socket, false) + this.missedHeartbeats.set(socket, missed + 1) socket.ping() } }, this.heartbeatIntervalMs) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index e73c76f8c0..6c2cc1da30 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -50,7 +50,7 @@ describe('Remote stream mux server carrier lifecycle', () => { await closed }) - it('terminates a socket that does not answer the previous heartbeat', async () => { + it('requires two missed heartbeats before terminating an unresponsive socket', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) @@ -58,10 +58,39 @@ describe('Remote stream mux server carrier lifecycle', () => { const terminated = vi.spyOn(serverSocket, 'terminate') const closed = once(client, 'close') + await once(client, 'ping') + await once(client, 'ping') + expect(terminated).not.toHaveBeenCalled() await vi.waitFor(() => { expect(terminated).toHaveBeenCalledOnce() }) await closed }) + it('keeps the socket when a delayed Pong arrives before the final check', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url, false) + const serverSocket = acceptedSocket(entry.mux) + const terminated = vi.spyOn(serverSocket, 'terminate') + let finalCheck: (() => void) | undefined + const immediate = vi.spyOn(globalThis, 'setImmediate').mockImplementation((callback) => { + finalCheck = callback + return 0 as unknown as NodeJS.Immediate + }) + + try { + await once(client, 'ping') + await once(client, 'ping') + await vi.waitFor(() => { expect(finalCheck).toBeDefined() }) + serverSocket.emit('pong', Buffer.alloc(0)) + finalCheck?.() + expect(terminated).not.toHaveBeenCalled() + } finally { + immediate.mockRestore() + const closed = once(client, 'close') + client.close() + await closed + } + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -223,8 +252,8 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): return entry } -async function connect(url: string): Promise { - const socket = new WebSocket(url) +async function connect(url: string, autoPong = true): Promise { + const socket = new WebSocket(url, { autoPong }) await once(socket, 'open') return socket } diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 3020c80326..11a7f882b8 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -278,8 +278,7 @@ export class ConnectionController { this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { - // Transport failure: treat as generation failure, then enter the shared retry path. - if (!ac.signal.aborted) ac.abort() + // Source settlement and controller cancellation already abort the generation. } await failed @@ -306,12 +305,12 @@ export class ConnectionController { } } -/** Await source readiness without letting a stalled carrier wedge startup forever. */ +/** Await source readiness while reporting, but not cancelling, a slow Host. */ function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { let settled = false const timeout = setTimeout(() => { - finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) }) + console.warn(`[connection] generation is still not ready after ${String(timeoutMs)}ms`) }, timeoutMs) const aborted = (): void => { finish({ error: new Error('connection generation aborted', { cause: signal.reason }) }) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 3b9432995f..681f5ac242 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -568,7 +568,8 @@ describe('connection lifecycle', () => { } }) - it('rejects and retries a generation whose source never reports ready', async () => { + it('reports but retains a generation whose source is slow to report ready', async () => { + vi.useFakeTimers() const source = new FakeGenerationSource() source.suppressReady = true let connected = 0 @@ -580,13 +581,16 @@ describe('connection lifecycle', () => { ) controller.start() try { - await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) expect(source.activeCount).toBe(1) - await new Promise(resolve => setTimeout(resolve, 45)) + await vi.advanceTimersByTimeAsync(20) expect(connected).toBe(0) + expect(source.activeCount).toBe(1) + expect(warnSpy).toHaveBeenCalledWith('[connection] generation is still not ready after 20ms') } finally { controller.stop() warnSpy.mockRestore() + vi.useRealTimers() } }) From ddecdf6b33abcfda32534148322a5096b6a582e5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:30:51 +0800 Subject: [PATCH 37/38] test(web): wait for settled background job --- apps/web/tests/background-job-list.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 1030369886..284b359eeb 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') - const idle = page.getByRole('button', { name: '1 background job' }) + const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) From 14bab4422b12ab80cd79de59e086c12888fe00be Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:39:37 +0800 Subject: [PATCH 38/38] release(dsh): 0.1.2-alpha.3 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/api/session-controller/package.json | 2 +- packages/api/settings-controller/package.json | 2 +- packages/api/workspace-controller/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/acp-app/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/sdk-app/package.json | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/store/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-approval/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-chat/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-schedule/package.json | 2 +- packages/client/ui-session/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-python/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/experimental/agent-team-profile/package.json | 2 +- packages/experimental/agent-team-web-profile/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/client-ui-agent-team/package.json | 2 +- packages/experimental/inspector/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/experimental/webworker-packer/package.json | 2 +- packages/experimental/webworker-runtime/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/deepseek-llm-api-extensions/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/plugin-package-inventory-deepseek/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-log-deepseek/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/session/session-turn-outline/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent-report/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/subprocess/win32-process/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/test-support/session-snapshot/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/crypto/package.json | 2 +- packages/util/deque/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/time/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/util/values/package.json | 2 +- packages/util/workspace-path/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/webhook/webhook-github/package.json | 2 +- packages/webhook/webhook/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 253 files changed, 253 insertions(+), 253 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 013c6853ea..c589218149 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index 9896155714..f993d39587 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index 768d491cb1..d947add909 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index b838e5cd68..3a63dcaadc 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 9bafea1179..8a7c0eae65 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a4334e0e75..35b163fe48 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index cec763e14f..15e25e7d3b 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index a4b4e9dc19..f3814ccabe 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index a150b54113..ad1cdc0eab 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index e6759ab098..0da9c239e2 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index ab21021d04..99e31ef119 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 6186e91752..f540e88409 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 9c3e1b6b0f..7f1d904f68 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 9aabd94092..949b18fce1 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 143d77308e..b80a50fabc 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 3353859b35..6ad474cc64 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 165f74bfa3..64504dba29 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 8589093590..1a8c341479 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0ba57ce2b9..0d7157e23c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index bd863014f4..4bc6a1c61f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 85aff120ad..44435ef224 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 8f695e8780..8a8e39d433 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index ec09842019..d0672fa18d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index d8990587c5..81a0aa5186 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 1a6f3c2536..5ce6c2fb89 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index d79d0c4f0d..af83f7fd75 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 90ca1b9c0d..5b046fcc44 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 9aa7347eaf..f3368493f0 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 56b3e2a888..c57039ace5 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 5680c1498f..afadaddea4 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 017bd28a22..b5b0e91421 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 4465701cca..db291c8340 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index ede2a0e2d4..16b60e9889 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 0f7f5c1ead..0ad99131be 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index db44a0d0e5..28d2e06833 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 5c1d50e3af..a46e51af7c 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index e29cafee20..8d487be6ec 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 5dd609aff8..dd5739a61b 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 82c892b288..27db63c9cb 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index b5b4e63f52..0ad6338b80 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index d81c9bb3a7..91b394e826 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 06c26c2088..34d5bb4f65 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 20b5a88420..d1b7decda4 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 211ecf47be..6732995f6a 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 21d7154bb5..6dd17917d8 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 9cae9286e8..e408b86b21 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 4b49ccc137..e53bf2ed61 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a601a425fe..33dda0fa3e 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 62030a9515..03be8d7a4b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index d623b2c064..f0d5709c97 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 3e7dd4e674..d57a402348 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 808df9154a..7aa0911c2c 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 48fe793dcd..d64f4b9410 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 4daa55eaf8..88e84dd83c 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 2f83bf640b..5b480ae3e4 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 27c4e2853d..18cad207e6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index f44f524760..bb7d37f4e9 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index e0f98cfe8c..bf59531e60 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index fc4f2dfb6b..b3a57d921f 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index c4f97bdc6a..9b256fb0be 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 855be838c6..2bf43f0552 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 321a2598c7..f8f70de86c 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index d051df25f5..c862df293d 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index cd5aa2996c..9321aac015 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 25f7a326a7..7884f95a59 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 35d837f5b4..07203b0629 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index c083aa3d52..456ea1e06b 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 8a84e68267..a54fb76eb6 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 411246c986..6a0dda7491 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 3aaf8a5dcb..7dec4528ca 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 8497a3a51c..973788e828 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index f9add39fcb..7c286ef51f 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 9d624cc0c4..c3833ab082 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 19c50dfc46..119bdc34bd 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index ca543d05bb..76f3550933 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 46050f06bc..4a9762c3d5 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index eeffcbc65a..71f526dde7 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 6bfef9cc8e..7df2edd3e0 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 3644b30b30..9413b1d4d1 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 05795d5ca3..483d8c9eba 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 5bff0c4849..a754c15c34 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index e8b1b08a55..3e4be78030 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index b0f34fb471..0844250704 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 97126c55a2..7a54429077 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 8df05bbaaf..42addb36fe 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 6b86a2e189..399fc86da1 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index f314bb2651..ed04cd225d 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 424b72f0ac..18cb197d4c 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 142be2c67f..35293a1718 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index cc2855eecf..9d9079acd9 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index da942d32aa..c65ec96d25 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 8e549ee207..1e37547f07 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 057884d64f..8d083c2723 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 6938caaaa9..cdd1343de2 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 3268067584..c053446add 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index db96ecc607..da7a2e80ef 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 086f055eaf..d652faa9ef 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 2bd6ccb583..f9f1a454e3 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 2ad0c6c408..77fd181889 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 4673720092..dd615fb994 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 526fabfa2d..d04c9cc9e7 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 2795848392..654ac04ff5 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index b2e86a8bfd..e7c28e4501 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 56b3483a80..75820f674c 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index bf7af7b4f9..f31199b47d 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 98869145ad..3a3b713904 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 8aa91b3dcd..91c6c94c97 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 8427868024..aefd3d759b 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 4b2bf55bed..9117645bea 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 1c1941d2e3..4eb8da559c 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index d1d4c8d5fb..dc24ba2f9a 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 0b9c51fe5c..0a70ab50a9 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 429b9d99d6..fed2823aca 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index abc90ada9b..819bdd2a15 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index b1c78d0e79..5b25ef454a 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index c8ca64621a..4d64d22509 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index e297512328..a583d7d507 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index bc639b05c9..d3b689ca6c 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 28b881203f..68f197d544 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 0eb3e1f88e..9a91ad2aed 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 1329d21fd3..c9a35316a2 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 0ad77565f7..96dbc0575f 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 7e6ac2f0db..b388065a63 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 1a9afb1b66..9ee272792d 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 2b22b96b3e..d5668c0942 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 24996d8769..e754fd8d3c 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index cfe6a5fb76..a2cbd21924 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index d692dbd633..83c6afed32 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 6b3a0c124b..c3ebb0305a 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 6f1b2ddcf3..65f17dd8e7 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index a8d6a9a69f..458a604fef 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index efc45a2f97..a4bd9e8d28 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 5d8662b50e..71c2cf5610 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 6a3b59e6a1..3844455949 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index d3d46ef8cb..265f002981 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 7e4af851d5..bbed927a7f 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 48cf15aac4..714930108d 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 81f3ae3e65..e02fc06cd9 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 09b2025b44..3bed504dba 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 85fc3db29d..cfd134f485 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 7aca93a359..ce1a4c466a 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 4833766590..23d24babe6 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 428334feea..f0a55b2021 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 8c9c34e5cd..baa149122d 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index de3eb763c5..6ff7ff66ba 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index f35f7a8cda..db6aae0e9d 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 1ade60d909..846c359c77 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 352bc7e56c..c78fcb10e2 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a6eb4c8f9c..92fa0d62ba 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 61e0e600e6..fafce3d11a 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index fcd7e5e9ab..5af83e46bd 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index d40c86bea7..e841d164c9 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index b3582e5060..62e1f278a7 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 212c5a7fa0..3a72769215 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index c129ed0d41..00afdb93e0 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index b945f131e9..bbeca31693 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index f2e6e802a8..ca88b56bc5 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index cbf3373bfa..ac4ea8d193 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index c449cb16d3..f78e17b362 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 60cba7ebdd..93c50c2964 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 9e65292c97..9881396363 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 6c362a3d28..7e5e4398e7 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index bf83ca181a..01cec7f91d 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 5d0bdd6538..8ce5685ed8 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 5f413bbb77..b557ba69f1 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 02901c7313..e6ec7d7c35 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 102dda423e..82ce49c43f 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index f48f0e2868..3c0b6aba03 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index f0071d1497..4963228c45 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 2c930a54f8..b00f5ad581 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 8f985cfd0b..05b4e0e9b9 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 2717ec5c76..7f2fe9518a 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 2592db8cd2..da6d18e55f 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 9a1549a730..67acbbd584 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index d9024f735d..019a277528 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index a686ee63a1..ce206ca9dc 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index c1be737fc0..61d01e065e 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.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index a883898387..964c2fca7d 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 24c018ed02..ee38a5b8ba 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index e6fe12741d..c30e0c4bec 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 4d7fe4acea..066eb4423b 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 5e0d84ec7f..a9f20272c4 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 689664f8b0..fe251335f8 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 1345d66411..61e990fea3 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index c4e7edaa17..237daa097e 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index b167998e69..abcfd82ee8 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index a1ec19265d..1eec7f247b 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index c25a5f181e..a850d7267c 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 49c3942a30..e881b5af4e 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 0eaf64b821..3a50b595bd 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index b2b9e2f50b..89cef0556b 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index d549231237..8c781e8ec4 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index fe05359bb5..d036c2cf7a 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index b02ab12364..2cf803a056 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 98c9a0ae67..afc6cc2c29 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index a1aea3f37c..2f92576e7b 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 1ef2fd8852..2b99e2f67e 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index bc6ed8df05..d9ddc6354c 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index d15a93e136..13265e1f7c 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index cbd5bc2570..6ca591a5ed 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index b85c698c24..9713052168 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index bb8a733a36..0b6264cb41 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index e46db7d9ec..0db7bdffbb 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 7f6d4f672c..650af56015 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index e13bb45018..c6182ecebc 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 31a3aa0013..887111625f 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 409fad1e26..a2a1b1d3a4 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 92637766f0..e33435f317 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index f8b0987b12..a7ad3ecdb7 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index d8c2a02fc2..f5c742242c 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 8e0cafbfb8..8160177479 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 8d968883b8..838e941f32 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index c584c162bf..ef09857b90 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 2f60a83d27..100c84a312 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 74a7ed1a2b..b6dfd6732a 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index f6e03e76b5..1613707fcb 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index cd1bf4d366..aa009ff57e 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index d67580bf51..bf50e9c698 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 22a04c704d..57722159ca 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index ccce7a4e2f..b85b14747b 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index a95547fd2e..90a45ea3bc 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 57e6195858..6fe0957930 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index b8ce37b908..b441032ea7 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 2cd2516a6f..1b2f215c2f 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index fd3efd1361..c7bafbbb26 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index be1d0a6d68..8a96f3e98f 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 41a879e8c4..ea60c199a8 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 94786e6394..2eab9ad65f 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 577d31f0b3..7dea605024 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 3f8792cba5..12e2cb39fe 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded-string primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index b93eb0307a..1a85a1499c 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index b5a8bd39d1..cd199daf05 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index a5c857ef7c..95d7885bcf 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 06ab4ba55f..d91ebc6ba2 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 9d0516cbe3..ce49914ac3 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index b8f8a0343e..8f25770d57 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 1c422b8a78..5973808833 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 940eb01c14..6a85edbfd6 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index f18a8bd0ea..e63754bc86 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 81a4841617..0ae6549178 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index c4a99ae8cb..c815de56ce 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index ffe992baa6..2c8db1586e 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 471bcf912e..2eaf5d16ed 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a064c8f1bf..37dfacd279 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 32775c3f6c..e4acdff92f 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 78d3c8fda4..b0542aaf76 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 7d81eebcaf..ef72ae6112 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 98c5795000..65175c91ab 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 08024a74c1..43a7bc7378 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 93c3866b48..688e97bacb 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 3440975584..59845fb526 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 2a41cb7b0e..d7785293e5 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 8d14357419..43dba0a32a 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.2-alpha.2", + "version": "0.1.2-alpha.3", "publishConfig": { "access": "public" },